+ <% end %>
+
+ <%= if @active_tab == :membership_fees do %>
+ <%!-- Membership Fees Tab Content --%>
+ <.live_component
+ module={MvWeb.MemberLive.Show.MembershipFeesComponent}
+ id={"membership-fees-#{@member.id}"}
+ member={@member}
+ />
+ <% end %>
"""
end
@impl true
def mount(_params, _session, socket) do
- {:ok, socket}
+ {:ok, assign(socket, :active_tab, :contact)}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
+ # Load custom fields once using assign_new to avoid repeated queries
+ socket =
+ assign_new(socket, :custom_fields, fn ->
+ Mv.Membership.CustomField
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!()
+ end)
+
query =
Mv.Membership.Member
|> filter(id == ^id)
- |> load([:user, custom_field_values: [:custom_field]])
+ |> load([
+ :user,
+ :membership_fee_type,
+ custom_field_values: [:custom_field],
+ membership_fee_cycles: [:membership_fee_type]
+ ])
member = Ash.read_one!(query)
+ # Calculate last and current cycle status from loaded cycles
+ last_cycle_status = get_last_cycle_status(member)
+ current_cycle_status = get_current_cycle_status(member)
+
+ member =
+ member
+ |> Map.put(:last_cycle_status, last_cycle_status)
+ |> Map.put(:current_cycle_status, current_cycle_status)
+
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:member, member)}
end
+ @impl true
+ def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
+ {:noreply, assign(socket, :active_tab, :contact)}
+ end
+
+ def handle_event("switch_tab", %{"tab" => "membership_fees"}, socket) do
+ {:noreply, assign(socket, :active_tab, :membership_fees)}
+ end
+
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
@@ -236,14 +325,56 @@ defmodule MvWeb.MemberLive.Show do
"""
end
+ # Renders a mailto link if email is present, otherwise renders empty value placeholder
+ attr :email, :string, required: true
+ attr :display, :string, default: nil
+
+ defp mailto_link(assigns) do
+ display_text = assigns.display || assigns.email
+
+ if assigns.email && String.trim(assigns.email) != "" do
+ assigns = %{email: assigns.email, display: display_text}
+
+ ~H"""
+
+ """
+ else
+ render_empty_value()
+ end
+ end
+
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
- defp display_value(nil), do: ""
- defp display_value(""), do: ""
+ defp display_value(nil), do: render_empty_value()
+ defp display_value(""), do: render_empty_value()
defp display_value(value), do: value
+ 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_status_label(nil), do: gettext("No status")
+
+ defp get_last_cycle_status(member) do
+ case MembershipFeeHelpers.get_last_completed_cycle(member) do
+ nil -> nil
+ cycle -> cycle.status
+ end
+ end
+
+ defp get_current_cycle_status(member) do
+ case MembershipFeeHelpers.get_current_cycle(member) do
+ nil -> nil
+ cycle -> cycle.status
+ end
+ end
+
defp format_address(member) do
street_part =
[member.street, member.house_number]
@@ -272,20 +403,34 @@ defmodule MvWeb.MemberLive.Show do
defp format_date(date), do: to_string(date)
- # Sorts custom field values by custom field name
- defp sort_custom_field_values(custom_field_values) do
- Enum.sort_by(custom_field_values, fn cfv ->
- (cfv.custom_field && cfv.custom_field.name) || ""
+ # Finds custom field value for a given custom field id
+ # Returns the value (not the CustomFieldValue struct) or nil
+ defp find_custom_field_value(nil, _custom_field_id), do: nil
+
+ defp find_custom_field_value(custom_field_values, custom_field_id)
+ when is_list(custom_field_values) do
+ Enum.find_value(custom_field_values, fn cfv ->
+ if cfv.custom_field_id == custom_field_id or
+ (cfv.custom_field && cfv.custom_field.id == custom_field_id) do
+ cfv.value
+ end
end)
end
+ defp find_custom_field_value(_custom_field_values, _custom_field_id), do: nil
+
# Formats custom field value based on type
+ # Handles both CustomFieldValue structs and direct values
+ defp format_custom_field_value(nil, _type), do: render_empty_value()
+
+ defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
+ format_custom_field_value(cfv.value, value_type)
+ end
+
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
format_custom_field_value(value, type)
end
- defp format_custom_field_value(nil, _type), do: "—"
-
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
if value, do: gettext("Yes"), else: gettext("No")
end
@@ -295,11 +440,15 @@ defmodule MvWeb.MemberLive.Show do
end
defp format_custom_field_value(value, :email) when is_binary(value) do
- assigns = %{email: value}
+ if String.trim(value) == "" do
+ render_empty_value()
+ else
+ assigns = %{email: value}
- ~H"""
-
- """
+ ~H"""
+ <.mailto_link email={@email} display={@email} />
+ """
+ end
end
defp format_custom_field_value(value, :integer) when is_integer(value) do
@@ -307,8 +456,22 @@ defmodule MvWeb.MemberLive.Show do
end
defp format_custom_field_value(value, _type) when is_binary(value) do
- if String.trim(value) == "", do: "—", else: value
+ if String.trim(value) == "", do: render_empty_value(), else: value
end
defp format_custom_field_value(value, _type), do: to_string(value)
+
+ # Renders accessible placeholder for empty values
+ # Uses translated text for screen readers while maintaining visual consistency
+ # The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
+ defp render_empty_value do
+ assigns = %{text: gettext("Not set")}
+
+ ~H"""
+
+ """
+ end
end
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
new file mode 100644
index 0000000..f96fd73
--- /dev/null
+++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -0,0 +1,927 @@
+defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
+ @moduledoc """
+ LiveComponent for displaying and managing membership fees for a member.
+
+ ## Features
+ - Display all membership fee cycles in a table
+ - Change membership fee type (with same-interval validation)
+ - Change cycle status (paid/unpaid/suspended)
+ - Regenerate cycles manually
+ - Delete cycles (with confirmation)
+ - Edit cycle amount (with modal)
+ """
+ use MvWeb, :live_component
+
+ require Ash.Query
+
+ 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
+ def render(assigns) do
+ ~H"""
+
+ """
+ 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(:cycles, cycles)
+ |> assign(:available_fee_types, available_fee_types)
+ |> 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
+
+ @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, domain: MembershipFees)
+
+ # 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: action, domain: MembershipFees) do
+ {:ok, updated_cycle} ->
+ updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
+
+ {:noreply,
+ socket
+ |> assign(:cycles, updated_cycles)
+ |> put_flash(:info, gettext("Cycle status updated"))}
+
+ {:error, %Ash.Error.Invalid{} = error} ->
+ error_msg =
+ Enum.map_join(error.errors, ", ", fn e -> e.message end)
+
+ {:noreply,
+ socket
+ |> put_flash(
+ :error,
+ gettext("Failed to update cycle status: %{errors}", errors: error_msg)
+ )}
+
+ {:error, error} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, format_error(error))}
+ end
+ end
+
+ def handle_event("regenerate_cycles", _params, socket) do
+ socket = assign(socket, :regenerating, true)
+ member = socket.assigns.member
+
+ case CycleGenerator.generate_cycles_for_member(member.id) do
+ {:ok, _new_cycles, _notifications} ->
+ # 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("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)
+
+ # Normalize comma to dot for decimal parsing (German locale support)
+ normalized_amount_str = String.replace(amount_str, ",", ".")
+
+ case Decimal.parse(normalized_amount_str) do
+ {amount, _} when is_struct(amount, Decimal) ->
+ case cycle
+ |> Ash.Changeset.for_update(:update, %{amount: amount})
+ |> Ash.update(domain: MembershipFees) 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, domain: MembershipFees) 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"))}
+
+ {:ok, _destroyed} ->
+ # Handle case where return_destroyed? is true
+ 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
+
+ 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
+ # Validate confirmation (case-insensitive, trimmed)
+ confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
+ expected = String.downcase(gettext("Yes"))
+
+ if confirmation != expected do
+ {:noreply,
+ socket
+ |> assign(:deleting_all_cycles, false)
+ |> assign(:delete_all_confirmation, "")
+ |> put_flash(:error, gettext("Confirmation text does not match"))}
+ else
+ member = socket.assigns.member
+
+ # Delete all cycles atomically using Ecto query
+ import Ecto.Query
+
+ deleted_count =
+ Mv.Repo.delete_all(
+ from c in Mv.MembershipFees.MembershipFeeCycle,
+ where: c.member_id == ^member.id
+ )
+
+ if deleted_count > 0 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
+ {:noreply,
+ socket
+ |> assign(:deleting_all_cycles, false)
+ |> assign(:delete_all_confirmation, "")
+ |> put_flash(:info, gettext("No cycles to delete"))}
+ end
+ 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", %{"date" => 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
+
+ # Normalize comma to dot for decimal parsing (German locale support)
+ normalized_amount_str = String.replace(amount_str, ",", ".")
+
+ amount =
+ case Decimal.parse(normalized_amount_str) do
+ {d, _} when is_struct(d, Decimal) -> {:ok, d}
+ :error -> {:error, :invalid_amount}
+ end
+
+ with {:ok, date} <- Date.from_iso8601(date_str),
+ {:ok, amount} <- amount,
+ 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, domain: MembershipFees) 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
+ 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")
+
+ 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
+
+ defp section_box(assigns) do
+ ~H"""
+
+ """
+ end
+end
diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex
index 5ca32e9..61774e8 100644
--- a/lib/mv_web/live/membership_fee_settings_live.ex
+++ b/lib/mv_web/live/membership_fee_settings_live.ex
@@ -30,11 +30,40 @@ defmodule MvWeb.MembershipFeeSettingsLive do
@impl true
def handle_event("validate", %{"settings" => params}, socket) do
- {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, params))}
+ # Normalize checkbox value: "on" -> true, missing -> false
+ normalized_params =
+ if Map.has_key?(params, "include_joining_cycle") do
+ params
+ |> Map.update("include_joining_cycle", false, fn
+ "on" -> true
+ "true" -> true
+ true -> true
+ _ -> false
+ end)
+ else
+ Map.put(params, "include_joining_cycle", false)
+ end
+
+ {:noreply,
+ assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, normalized_params))}
end
def handle_event("save", %{"settings" => params}, socket) do
- case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
+ # Normalize checkbox value: "on" -> true, missing -> false
+ normalized_params =
+ if Map.has_key?(params, "include_joining_cycle") do
+ params
+ |> Map.update("include_joining_cycle", false, fn
+ "on" -> true
+ "true" -> true
+ true -> true
+ _ -> false
+ end)
+ else
+ Map.put(params, "include_joining_cycle", false)
+ end
+
+ case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do
{:ok, updated_settings} ->
{:noreply,
socket
@@ -101,8 +130,11 @@ defmodule MvWeb.MembershipFeeSettingsLive do
)})
- <%= for {msg, _opts} <- @form.errors[:default_membership_fee_type_id] || [] do %>
-
+ <%= if @form.errors[:default_membership_fee_type_id] do %>
+ <%= for error <- List.wrap(@form.errors[:default_membership_fee_type_id]) do %>
+ <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
+
{gettext(
@@ -125,8 +157,11 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{gettext("Include joining cycle")}
- <%= for {msg, _opts} <- @form.errors[:include_joining_cycle] || [] do %>
-
+ <%= if @form.errors[:include_joining_cycle] do %>
+ <%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %>
+ <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
+
diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex
new file mode 100644
index 0000000..5acb8c9
--- /dev/null
+++ b/lib/mv_web/live/membership_fee_type_live/form.ex
@@ -0,0 +1,455 @@
+defmodule MvWeb.MembershipFeeTypeLive.Form do
+ @moduledoc """
+ LiveView form for creating and editing membership fee types (Admin).
+
+ ## Features
+ - Create new membership fee types
+ - Edit existing membership fee types (name, amount, description - NOT interval)
+ - Amount change warning modal (shows impact on members)
+ - Interval field grayed out on edit
+
+ ## Permissions
+ - Admin only
+ """
+ use MvWeb, :live_view
+
+ require Ash.Query
+
+ alias Mv.MembershipFees
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Membership.Member
+ alias MvWeb.Helpers.MembershipFeeHelpers
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {@page_title}
+ <:subtitle>
+ {gettext("Use this form to manage membership fee types in your database.")}
+
+
+
+ <.form
+ class="max-w-xl"
+ for={@form}
+ id="membership-fee-type-form"
+ phx-change="validate"
+ phx-submit="save"
+ >
+ <.input field={@form[:name]} type="text" label={gettext("Name")} required />
+
+ <.input
+ field={@form[:amount]}
+ label={gettext("Amount")}
+ required
+ phx-debounce="blur"
+ />
+
+
+
+ <.input
+ field={@form[:description]}
+ type="textarea"
+ label={gettext("Description")}
+ rows="3"
+ />
+
+
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save Membership Fee Type")}
+
+ <.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
+ {gettext("Cancel")}
+
+
+
+
+ <%!-- Amount Change Warning Modal --%>
+ <%= if @show_amount_warning do %>
+
+ <% end %>
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ membership_fee_type =
+ case params["id"] do
+ nil -> nil
+ id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
+ end
+
+ page_title =
+ if is_nil(membership_fee_type),
+ do: gettext("New Membership Fee Type"),
+ else: gettext("Edit Membership Fee Type")
+
+ {:ok,
+ socket
+ |> assign(:return_to, return_to(params["return_to"]))
+ |> assign(:membership_fee_type, membership_fee_type)
+ |> assign(:page_title, page_title)
+ |> assign(:show_amount_warning, false)
+ |> assign(:old_amount, nil)
+ |> assign(:new_amount, nil)
+ |> assign(:affected_member_count, 0)
+ |> assign(:pending_amount, nil)
+ |> assign_form()}
+ end
+
+ defp return_to("index"), do: "index"
+ defp return_to(_), do: "index"
+
+ @impl true
+ def handle_event("validate", %{"membership_fee_type" => params}, socket) do
+ # Merge with existing form values to preserve unchanged fields
+ # Extract values directly from form fields to get current state
+ existing_values = get_existing_form_values(socket.assigns.form)
+
+ # Merge existing values with new params (new params take precedence)
+ merged_params = Map.merge(existing_values, params)
+
+ # Convert interval string to atom if present
+ merged_params =
+ if Map.has_key?(merged_params, "interval") && is_binary(merged_params["interval"]) &&
+ merged_params["interval"] != "" do
+ Map.update!(merged_params, "interval", fn val ->
+ String.to_existing_atom(val)
+ end)
+ else
+ merged_params
+ end
+
+ # Let Ash handle validation automatically - it will validate Decimal format
+ validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
+
+ # Check if amount changed on edit
+ socket = check_amount_change(socket, merged_params)
+
+ {:noreply, assign(socket, form: validated_form)}
+ end
+
+ def handle_event("cancel_amount_change", _params, socket) do
+ # Reset form to original amount
+ form = socket.assigns.form
+
+ original_amount =
+ if socket.assigns.membership_fee_type do
+ socket.assigns.membership_fee_type.amount
+ else
+ Decimal.new("0")
+ end
+
+ # Update form with original amount
+ updated_form =
+ AshPhoenix.Form.validate(form, %{
+ "amount" => Decimal.to_string(original_amount)
+ })
+
+ {:noreply,
+ socket
+ |> assign(:form, updated_form)
+ |> assign(:show_amount_warning, false)
+ |> assign(:pending_amount, nil)}
+ end
+
+ def handle_event("confirm_amount_change", _params, socket) do
+ # Update form with pending amount and hide warning
+ # Preserve all existing form values (name, description, etc.)
+ form = socket.assigns.form
+ existing_values = get_existing_form_values(form)
+
+ updated_form =
+ if socket.assigns.pending_amount do
+ # Merge existing values with confirmed amount to preserve all fields
+ merged_params = Map.put(existing_values, "amount", socket.assigns.pending_amount)
+ AshPhoenix.Form.validate(form, merged_params)
+ else
+ form
+ end
+
+ {:noreply,
+ socket
+ |> assign(:form, updated_form)
+ |> assign(:show_amount_warning, false)
+ |> assign(:pending_amount, nil)}
+ end
+
+ def handle_event("save", %{"membership_fee_type" => params}, socket) do
+ # If amount warning was shown but not confirmed, don't save
+ if socket.assigns.show_amount_warning do
+ {:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
+ else
+ case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
+ {:ok, membership_fee_type} ->
+ notify_parent({:saved, membership_fee_type})
+
+ socket =
+ socket
+ |> put_flash(:info, gettext("Membership fee type saved successfully"))
+ |> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
+
+ {:noreply, socket}
+
+ {:error, form} ->
+ {:noreply, assign(socket, form: form)}
+ end
+ end
+ end
+
+ @spec notify_parent(any()) :: any()
+ defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
+
+ @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
+ defp assign_form(%{assigns: %{membership_fee_type: membership_fee_type}} = socket) do
+ form =
+ if membership_fee_type do
+ AshPhoenix.Form.for_update(
+ membership_fee_type,
+ :update,
+ domain: MembershipFees,
+ as: "membership_fee_type"
+ )
+ else
+ AshPhoenix.Form.for_create(
+ MembershipFeeType,
+ :create,
+ domain: MembershipFees,
+ as: "membership_fee_type"
+ )
+ end
+
+ assign(socket, form: to_form(form))
+ end
+
+ # Helper to extract existing form values to preserve them when only one field changes
+ defp get_existing_form_values(form) do
+ # Extract values directly from form fields to get current state
+ # This ensures we get the actual current values, not just initial params
+ %{}
+ |> extract_form_value(form, :name, &to_string/1)
+ |> extract_form_value(form, :amount, &format_amount_value/1)
+ |> extract_form_value(form, :interval, &format_interval_value/1)
+ |> extract_form_value(form, :description, &to_string/1)
+ end
+
+ # Helper to extract a single form field value
+ defp extract_form_value(acc, form, field, formatter) do
+ if form[field] && form[field].value do
+ Map.put(acc, to_string(field), formatter.(form[field].value))
+ else
+ acc
+ end
+ end
+
+ # Formats amount value (Decimal or string) to string
+ defp format_amount_value(%Decimal{} = amount), do: Decimal.to_string(amount, :normal)
+ defp format_amount_value(value) when is_binary(value), do: value
+ defp format_amount_value(value), do: to_string(value)
+
+ # Formats interval value (atom or string) to string
+ defp format_interval_value(value) when is_atom(value), do: Atom.to_string(value)
+ defp format_interval_value(value) when is_binary(value), do: value
+ defp format_interval_value(value), do: to_string(value)
+
+ @spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
+ defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
+
+ @spec get_affected_member_count(String.t()) :: non_neg_integer()
+ # Checks if amount changed and updates socket assigns accordingly
+ defp check_amount_change(socket, params) do
+ if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
+ # Get current amount from form and new amount from params
+ current_form_amount = get_existing_form_values(socket.assigns.form)["amount"]
+ new_amount_str = params["amount"]
+
+ # Only check amount change if amount field is actually being changed in this validation
+ # This prevents re-triggering the warning when other fields (name, description) are edited
+ if current_form_amount != new_amount_str do
+ handle_amount_change(socket, new_amount_str, socket.assigns.membership_fee_type.amount)
+ else
+ # Amount didn't change in this validation - keep current warning state
+ # If warning was already confirmed (pending_amount is nil and show_amount_warning is false), keep it hidden
+ # If warning is shown but not confirmed, keep it shown
+ socket
+ end
+ else
+ socket
+ end
+ end
+
+ # Handles amount change detection and warning assignment
+ defp handle_amount_change(socket, new_amount_str, old_amount) do
+ case Decimal.parse(new_amount_str) do
+ {new_amount, _} when is_struct(new_amount, Decimal) ->
+ if Decimal.compare(new_amount, old_amount) != :eq do
+ show_amount_warning(socket, old_amount, new_amount, new_amount_str)
+ else
+ hide_amount_warning(socket)
+ end
+
+ :error ->
+ hide_amount_warning(socket)
+ end
+ end
+
+ # Shows amount change warning with affected member count
+ # Only calculates count if warning is being shown for the first time (false -> true)
+ defp show_amount_warning(socket, old_amount, new_amount, new_amount_str) do
+ # Only calculate count if warning is not already shown (optimization)
+ affected_count =
+ if socket.assigns.show_amount_warning do
+ # Warning already shown, reuse existing count
+ socket.assigns.affected_member_count
+ else
+ # Warning being shown for first time, calculate count
+ get_affected_member_count(socket.assigns.membership_fee_type.id)
+ end
+
+ socket
+ |> assign(:show_amount_warning, true)
+ |> assign(:old_amount, old_amount)
+ |> assign(:new_amount, new_amount)
+ |> assign(:affected_member_count, affected_count)
+ |> assign(:pending_amount, new_amount_str)
+ end
+
+ # Hides amount change warning
+ defp hide_amount_warning(socket) do
+ socket
+ |> assign(:show_amount_warning, false)
+ |> assign(:pending_amount, nil)
+ end
+
+ defp get_affected_member_count(fee_type_id) do
+ case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
+ {:ok, count} -> count
+ _ -> 0
+ end
+ end
+end
diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex
new file mode 100644
index 0000000..176d4e1
--- /dev/null
+++ b/lib/mv_web/live/membership_fee_type_live/index.ex
@@ -0,0 +1,224 @@
+defmodule MvWeb.MembershipFeeTypeLive.Index do
+ @moduledoc """
+ LiveView for managing membership fee types (Admin).
+
+ ## Features
+ - List all membership fee types
+ - Display: Name, Amount, Interval, Member count
+ - Create new membership fee types
+ - Edit existing membership fee types (name, amount, description - NOT interval)
+ - Delete membership fee types (if no members assigned)
+
+ ## Permissions
+ - Admin only
+ """
+ use MvWeb, :live_view
+
+ require Ash.Query
+
+ alias Mv.MembershipFees
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Membership
+ alias Mv.Membership.Member
+ alias MvWeb.Helpers.MembershipFeeHelpers
+
+ @impl true
+ def mount(_params, _session, socket) do
+ fee_types = load_membership_fee_types()
+ member_counts = load_member_counts(fee_types)
+
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Membership Fee Types"))
+ |> assign(:membership_fee_types, fee_types)
+ |> assign(:member_counts, member_counts)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {gettext("Membership Fee Types")}
+ <:subtitle>
+ {gettext("Manage membership fee types for membership fees.")}
+
+ <:actions>
+ <.button variant="primary" navigate={~p"/membership_fee_types/new"}>
+ <.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
+
+
+
+
+ <.table
+ id="membership_fee_types"
+ rows={@membership_fee_types}
+ row_id={fn mft -> "mft-#{mft.id}" end}
+ >
+ <:col :let={mft} label={gettext("Name")}>
+ {mft.name}
+ {mft.description}
+
+
+ <:col :let={mft} label={gettext("Amount")}>
+ {MembershipFeeHelpers.format_currency(mft.amount)}
+
+
+ <:col :let={mft} label={gettext("Interval")}>
+
+ {MembershipFeeHelpers.format_interval(mft.interval)}
+
+
+
+ <:col :let={mft} label={gettext("Members")}>
+ {get_member_count(mft, @member_counts)}
+
+
+ <:action :let={mft}>
+ <.link
+ navigate={~p"/membership_fee_types/#{mft.id}/edit"}
+ class="btn btn-ghost btn-xs"
+ aria-label={gettext("Edit membership fee type")}
+ >
+ <.icon name="hero-pencil" class="size-4" />
+
+
+
+ <:action :let={mft}>
+ 0}
+ class="tooltip tooltip-left"
+ data-tip={
+ gettext("Cannot delete - %{count} member(s) assigned",
+ count: get_member_count(mft, @member_counts)
+ )
+ }
+ >
+
+
+
+
+
+
+ <.info_card />
+
+ """
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
+
+ case Ash.destroy(fee_type, domain: MembershipFees) do
+ :ok ->
+ updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
+ updated_counts = Map.delete(socket.assigns.member_counts, id)
+
+ {:noreply,
+ socket
+ |> assign(:membership_fee_types, updated_types)
+ |> assign(:member_counts, updated_counts)
+ |> put_flash(:info, gettext("Membership fee type deleted"))}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_error(error))}
+ end
+ end
+
+ # Helper functions
+
+ defp load_membership_fee_types do
+ MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!(domain: MembershipFees)
+ end
+
+ # Loads all member counts for fee types in a single query to avoid N+1 queries
+ defp load_member_counts(fee_types) do
+ fee_type_ids = Enum.map(fee_types, & &1.id)
+
+ # Load all members with membership_fee_type_id in a single query
+ members =
+ Member
+ |> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
+ |> Ash.Query.select([:membership_fee_type_id])
+ |> Ash.read!(domain: Membership)
+
+ # Group by membership_fee_type_id and count
+ members
+ |> Enum.group_by(& &1.membership_fee_type_id)
+ |> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
+ |> Map.new()
+ end
+
+ # Gets member count from preloaded assigns map
+ defp get_member_count(fee_type, member_counts) do
+ Map.get(member_counts, fee_type.id, 0)
+ end
+
+ 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")
+
+ # Info card explaining the membership fee type concept
+ defp info_card(assigns) do
+ ~H"""
+
+
+
+ <.icon name="hero-information-circle" class="size-5" />
+ {gettext("About Membership Fee Types")}
+
+
+
+ {gettext(
+ "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
+ )}
+
+
+ -
+ {gettext("Name & Amount")}
+ - {gettext("Can be changed at any time. Amount changes affect future periods only.")}
+
+ -
+ {gettext("Interval")}
+ - {gettext(
+ "Fixed after creation. Members can only switch between types with the same interval."
+ )}
+
+ -
+ {gettext("Deletion")}
+ - {gettext("Only possible if no members are assigned to this type.")}
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/mv_web/member_live/index/membership_fee_status.ex b/lib/mv_web/member_live/index/membership_fee_status.ex
new file mode 100644
index 0000000..bf4dd53
--- /dev/null
+++ b/lib/mv_web/member_live/index/membership_fee_status.ex
@@ -0,0 +1,181 @@
+defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
+ @moduledoc """
+ Helper module for membership fee status display in member list view.
+
+ Provides functions to efficiently load and determine cycle status for members
+ in the list view, avoiding N+1 queries.
+ """
+
+ use Gettext, backend: MvWeb.Gettext
+
+ alias Mv.Membership.Member
+ alias MvWeb.Helpers.MembershipFeeHelpers
+
+ @doc """
+ Loads membership fee cycles for members efficiently.
+
+ Preloads cycles with membership_fee_type relationship to avoid N+1 queries.
+ Note: This loads all cycles for each member. The filtering to get the relevant
+ cycle (current or last completed) happens in `get_cycle_status_for_member/2`.
+
+ ## Parameters
+
+ - `query` - Ash query for members
+ - `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
+ - `today` - Optional date to use as reference (currently unused, kept for API compatibility)
+
+ ## Returns
+
+ Modified query with cycles loaded
+
+ ## Performance
+
+ Uses Ash.Query.load to efficiently preload cycles in a single query.
+ All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
+ """
+ @spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
+ def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
+ # Load membership_fee_type and cycles
+ query
+ |> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
+ end
+
+ @doc """
+ Gets the cycle status for a member.
+
+ Returns the status of either the last completed cycle or the current cycle,
+ depending on the `show_current` parameter.
+
+ ## Parameters
+
+ - `member` - Member struct with loaded cycles and membership_fee_type
+ - `show_current` - If true, get current cycle status; if false, get last completed cycle status
+
+ ## Returns
+
+ - `:paid`, `:unpaid`, or `:suspended` if cycle exists
+ - `nil` if no cycle exists
+
+ ## Examples
+
+ # Get last completed cycle status
+ iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false)
+ :paid
+
+ # Get current cycle status
+ iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true)
+ :unpaid
+ """
+ @spec get_cycle_status_for_member(Member.t(), boolean(), Date.t() | nil) ::
+ :paid | :unpaid | :suspended | nil
+ def get_cycle_status_for_member(member, show_current \\ false, today \\ nil) do
+ cycle =
+ if show_current do
+ MembershipFeeHelpers.get_current_cycle(member, today)
+ else
+ MembershipFeeHelpers.get_last_completed_cycle(member, today)
+ end
+
+ case cycle do
+ nil -> nil
+ cycle -> cycle.status
+ end
+ end
+
+ @doc """
+ Formats cycle status as a badge component.
+
+ Returns a map with badge information for rendering in templates.
+
+ ## Parameters
+
+ - `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`)
+
+ ## Returns
+
+ Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
+
+ ## Examples
+
+ iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
+ %{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
+
+ iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
+ nil
+ """
+ @spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
+ %{color: String.t(), icon: String.t(), label: String.t()} | nil
+ def format_cycle_status_badge(nil), do: nil
+
+ def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
+ %{
+ color: MembershipFeeHelpers.status_color(status),
+ icon: MembershipFeeHelpers.status_icon(status),
+ label: format_status_label(status)
+ }
+ end
+
+ @doc """
+ Filters members by cycle status (paid or unpaid).
+
+ Returns members that have the specified status in either the last completed cycle
+ or the current cycle, depending on `show_current`.
+
+ ## Parameters
+
+ - `members` - List of member structs with loaded cycles
+ - `status` - Cycle status to filter by (`:paid` or `:unpaid`)
+ - `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
+
+ ## Returns
+
+ List of members with the specified cycle status
+
+ ## Examples
+
+ # Filter unpaid members in last cycle
+ iex> filter_members_by_cycle_status(members, :unpaid, false)
+ [%Member{}, ...]
+
+ # Filter paid members in current cycle
+ iex> filter_members_by_cycle_status(members, :paid, true)
+ [%Member{}, ...]
+ """
+ @spec filter_members_by_cycle_status([Member.t()], :paid | :unpaid, boolean()) :: [Member.t()]
+ def filter_members_by_cycle_status(members, status, show_current \\ false)
+ when status in [:paid, :unpaid] do
+ Enum.filter(members, fn member ->
+ member_status = get_cycle_status_for_member(member, show_current)
+ member_status == status
+ end)
+ end
+
+ @doc """
+ Filters members by unpaid cycle status.
+
+ Returns members that have unpaid cycles in either the last completed cycle
+ or the current cycle, depending on `show_current`.
+
+ ## Parameters
+
+ - `members` - List of member structs with loaded cycles
+ - `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
+
+ ## Returns
+
+ List of members with unpaid cycles
+
+ ## Deprecated
+
+ This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
+ """
+ @spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
+ def filter_unpaid_members(members, show_current \\ false) do
+ filter_members_by_cycle_status(members, :unpaid, show_current)
+ end
+
+ # Private helper function to format status label
+ defp format_status_label(:paid), do: gettext("Paid")
+ defp format_status_label(:unpaid), do: gettext("Unpaid")
+ defp format_status_label(:suspended), do: gettext("Suspended")
+end
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 887628e..9a871c9 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -72,6 +72,11 @@ defmodule MvWeb.Router do
# Membership Fee Settings
live "/membership_fee_settings", MembershipFeeSettingsLive
+ # Membership Fee Types Management
+ live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
+ live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
+ live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
+
# Contribution Management (Mock-ups)
live "/contribution_types", ContributionTypeLive.Index, :index
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex
index 3750bcb..f10e0d2 100644
--- a/lib/mv_web/translations/member_fields.ex
+++ b/lib/mv_web/translations/member_fields.ex
@@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:first_name), do: gettext("First Name")
def label(:last_name), do: gettext("Last Name")
def label(:email), do: gettext("Email")
- def label(:paid), do: gettext("Paid")
def label(:phone_number), do: gettext("Phone")
def label(:join_date), do: gettext("Join Date")
def label(:exit_date), do: gettext("Exit Date")
diff --git a/mix.lock b/mix.lock
index 1dd3d48..1808eba 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,27 +1,27 @@
%{
- "ash": {:hex, :ash, "3.11.1", "9794620bffeb83d1803d92a64e7803f70b57372eb4addba5c12a24343cd04e1a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e0074302bb88d667635fcbfdacbf8a641c53973a3902d0e744f567a49ec808fc"},
+ "ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
- "ash_authentication": {:hex, :ash_authentication, "4.13.3", "4d7a2e96b5a8fe68797ba0124cf40e6897c82b9fb69182fc5fdaac529b72d436", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "03d95b68766b28cda241e68217f6d1d839be350f7e8f20923162b163fb521b91"},
- "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.2", "a4646498a7e21fbdbe372f0d8afab08b5d7125b629f91bfcf8f4d1961bc9d57b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1dd6fa3a8f7d2563a53cf22aeda31770c855e927421af4d8bfaf480332acf721"},
+ "ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
+ "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
- "ash_postgres": {:hex, :ash_postgres, "2.6.26", "f995bac8762ae039d4fb94cf2b628430aa69b0b30bf4366b96b3543dbd679ae7", [:mix], [{:ash, "~> 3.9", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.12 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7050b3169d5a31d73f7e69a6564d1102cb2bc185e67ea428e78fda3da46a69fc"},
- "ash_sql": {:hex, :ash_sql, "0.3.15", "8b8daae1870ab37b4fb2f980e323194caf23cdb4218fef126c49cc11a01fa243", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "97432507b6f406eb2461e5d0fbf2e5104a8c61a2570322d11de2f124d822d8ff"},
+ "ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
+ "ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
- "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
+ "bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
- "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"},
+ "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
- "ecto_sql": {:hex, :ecto_sql, "3.13.3", "81f7067dd1951081888529002dbc71f54e5e891b69c60195040ea44697e1104a", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5751caea36c8f5dd0d1de6f37eceffea19d10bd53f20e5bbe31c45f2efc8944a"},
+ "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
@@ -56,7 +56,7 @@
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
@@ -64,24 +64,24 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
- "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
+ "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
- "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
+ "splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
- "swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"},
+ "swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
- "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
- "tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
+ "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
+ "tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
diff --git a/notes.md b/notes.md
deleted file mode 100644
index a5aa44f..0000000
--- a/notes.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# User-Member Association - Test Status
-
-## Test Files Created/Modified
-
-### 1. test/membership/member_available_for_linking_test.exs (NEU)
-**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
-**Grund**: Die `:available_for_linking` Action existiert noch nicht
-
-Tests:
-- ✗ returns only unlinked members and limits to 10
-- ✗ limits results to 10 members even when more exist
-- ✗ email match: returns only member with matching email when exists
-- ✗ email match: returns all unlinked members when no email match
-- ✗ search query: filters by first_name, last_name, and email
-- ✗ email match takes precedence over search query
-
-### 2. test/accounts/user_member_linking_test.exs (NEU)
-**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
-
-Tests:
-- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
-- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
-- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
-- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
-
-### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
-**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
-**Grund**: Member-Linking UI ist noch nicht implementiert
-
-Neue Tests:
-- ✗ shows linked member with unlink button when user has member
-- ✗ shows member search field when user has no member
-- ✗ selecting member and saving links member to user
-- ✗ unlinking member and saving removes member from user
-
-### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
-**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
-**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
-
-Neuer Test:
-- ✗ displays linked member name in user list
-
-## Zusammenfassung
-
-**Tests gesamt**: 13
-**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
-**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
-
-## Nächste Schritte
-
-1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
-2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
-3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
-4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
-5. Füge Gettext-Übersetzungen hinzu
-
-Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.
-
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index ec6812a..ef28ae8 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -17,6 +17,7 @@ msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@@ -37,6 +38,7 @@ msgstr "Stadt"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@@ -141,10 +143,9 @@ msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/translations/member_fields.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
@@ -170,6 +171,7 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
@@ -183,7 +185,6 @@ msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -196,9 +197,9 @@ msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
@@ -256,6 +257,8 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@@ -268,6 +271,7 @@ msgstr "Mitglied auswählen"
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
@@ -302,6 +306,7 @@ msgstr "Mitglied"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
@@ -309,6 +314,8 @@ msgstr "Mitglieder"
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@@ -771,11 +778,13 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität"
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
@@ -785,11 +794,6 @@ msgstr "Alle"
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
-#: lib/mv_web/live/components/payment_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Not paid"
-msgstr "Nicht bezahlt"
-
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
@@ -807,7 +811,6 @@ msgid "Back"
msgstr "Zurück"
#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Coming soon"
msgstr "Demnächst verfügbar"
@@ -818,40 +821,21 @@ msgstr "Demnächst verfügbar"
msgid "Contact Data"
msgstr "Kontaktdaten"
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Contribution"
-msgstr "Beitrag"
-
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Nr."
msgstr "Nr."
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Payment Cycle"
-msgstr "Zahlungszyklus"
-
-#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Data"
msgstr "Beitragsdaten"
#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr "Zahlungen"
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Pending"
-msgstr "Ausstehend"
-
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -866,27 +850,11 @@ msgid "Phone"
msgstr "Telefon"
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Save"
msgstr "Speichern"
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "This data is for demonstration purposes only (mockup)."
-msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "monthly"
-msgstr "monatlich"
-
-#: lib/mv_web/live/member_live/form.ex
-#, elixir-autogen, elixir-format
-msgid "yearly"
-msgstr "jährlich"
-
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Create Member"
@@ -906,6 +874,9 @@ msgstr "Über Beitragsarten"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Amount"
msgstr "Betrag"
@@ -916,6 +887,7 @@ msgid "Back to Settings"
msgstr "Zurück zu den Einstellungen"
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen."
@@ -935,7 +907,6 @@ msgstr "Beitragsart ändern"
msgid "Contribution Start"
msgstr "Beitragsbeginn"
-#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Types"
@@ -967,6 +938,7 @@ msgid "Current"
msgstr "Aktuell"
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
msgstr "Löschen"
@@ -982,6 +954,7 @@ msgid "Family"
msgstr "Familie"
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
@@ -991,9 +964,11 @@ msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitrags
msgid "Global Settings"
msgstr "Vereinsdaten"
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr "Halbjährlich"
@@ -1011,6 +986,9 @@ msgstr "Ehrenamtlich"
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Interval"
msgstr "Zyklus"
@@ -1080,9 +1058,11 @@ msgstr "Mitglied seit"
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden."
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Monthly"
msgstr "Monatlich"
@@ -1093,6 +1073,7 @@ msgid "Monthly fee for students and trainees"
msgstr "Monatlicher Beitrag für Studierende und Auszubildende"
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr "Name & Betrag"
@@ -1108,6 +1089,7 @@ msgid "No fee for honorary members"
msgstr "Kein Beitrag für ehrenamtliche Mitglieder"
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
@@ -1128,9 +1110,11 @@ msgstr "Bezahlt durch Überweisung"
msgid "Preview Mockup"
msgstr "Vorschau"
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr "Vierteljährlich"
@@ -1168,6 +1152,7 @@ msgid "Standard membership fee for regular members"
msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder"
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr "Status"
@@ -1188,6 +1173,9 @@ msgid "Suspend"
msgstr "Pausieren"
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr "Pausiert"
@@ -1208,7 +1196,11 @@ msgstr "Zeitraum"
msgid "Total Contributions"
msgstr "Gesamtbeiträge"
+#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr "Unbezahlt"
@@ -1218,9 +1210,11 @@ msgstr "Unbezahlt"
msgid "Why are not all contribution types shown?"
msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Yearly"
msgstr "jährlich"
@@ -1241,6 +1235,7 @@ msgid "Last name"
msgstr "Nachname"
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "None"
msgstr "Keine"
@@ -1301,6 +1296,7 @@ msgstr "Diese Felder können zusätzlich zu den normalen Daten ausgefüllt werde
msgid "Value Type"
msgstr "Wertetyp"
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/translations/field_types.ex
#, elixir-autogen, elixir-format
msgid "Date"
@@ -1326,11 +1322,6 @@ msgstr "Textfeld"
msgid "Yes/No-Selection"
msgstr "Ja/Nein-Auswahl"
-#: lib/mv_web/live/components/payment_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "All payment statuses"
-msgstr "Jeder Zahlungs-Zustand"
-
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
@@ -1346,6 +1337,11 @@ msgstr "Benutzerdefiniertes Feld speichern"
msgid "Save Custom Field Value"
msgstr "Benutzerdefinierten Feldwert speichern"
+#: lib/mv_web/components/core_components.ex
+#, elixir-autogen, elixir-format
+msgid "This field is required"
+msgstr "Dieses Feld ist erforderlich"
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings for membership fees."
@@ -1422,6 +1418,419 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "About Membership Fee Types"
+msgstr "Über Mitgliedsbeitragsarten"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Already paid cycles will remain with the old amount."
+msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag."
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "An error occurred"
+msgstr "Ein Fehler ist aufgetreten"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Are you sure you want to delete this cycle?"
+msgstr "Möchten Sie diesen Zyklus wirklich löschen?"
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Cannot delete - %{count} member(s) assigned"
+msgstr "Löschen nicht möglich – %{count} Mitglied(er) zugewiesen"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Change Amount?"
+msgstr "Betrag ändern?"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Changing the amount will affect %{count} member(s)."
+msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Confirm Change"
+msgstr "Änderung bestätigen"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Current Cycle"
+msgstr "Aktueller Zyklus"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Current amount"
+msgstr "Aktueller Betrag"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle"
+msgstr "Zyklus"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle amount updated"
+msgstr "Zyklusbetrag aktualisiert"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle deleted"
+msgstr "Zyklus gelöscht"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle status updated"
+msgstr "Zyklenstatus aktualisiert"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycles regenerated successfully"
+msgstr "Zyklen erfolgreich regeneriert"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete Cycle"
+msgstr "Zyklus löschen"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Cycle Amount"
+msgstr "Zyklusbetrag bearbeiten"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Membership Fee Type"
+msgstr "Mitgliedsbeitragsart bearbeiten"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to update cycle status: %{errors}"
+msgstr "Fehler beim Aktualisieren des Zyklenstatus: %{errors}"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Future unpaid cycles will be regenerated with the new amount."
+msgstr "Zukünftige unbezahlte Zyklen werden mit dem neuen Betrag regeneriert."
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Generate cycles from the last existing cycle to today"
+msgstr "Zyklen vom letzten existierenden Zyklus bis heute generieren"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Interval cannot be changed after creation."
+msgstr "Das Intervall kann nach der Erstellung nicht geändert werden."
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid amount format"
+msgstr "Ungültiges Betragsformat"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Last Cycle"
+msgstr "Letzter Zyklus"
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Manage membership fee types for membership fees."
+msgstr "Mitgliedsbeitragsarten für Mitgliedsbeiträge verwalten."
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Mark as paid"
+msgstr "Als bezahlt markieren"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Mark as suspended"
+msgstr "Als ausgesetzt markieren"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Mark as unpaid"
+msgstr "Als unbezahlt markieren"
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee"
+msgstr "Mitgliedsbeitrag"
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership Fee Status"
+msgstr "Mitgliedsbeitragsstatus"
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee Type"
+msgstr "Mitgliedsbeitragsart"
+
+#: lib/mv_web/components/layouts/navbar.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee Types"
+msgstr "Mitgliedsbeitragsarten"
+
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fees"
+msgstr "Mitgliedsbeiträge"
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type deleted"
+msgstr "Mitgliedsbeitragsart gelöscht"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type removed"
+msgstr "Mitgliedsbeitragsart entfernt"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type saved successfully"
+msgstr "Mitgliedsbeitragsart erfolgreich gespeichert"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type updated. Cycles regenerated."
+msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert."
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
+msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstrukturen. Jede Art hat ein festes Intervall (monatlich, vierteljährlich, halbjährlich, jährlich), das nach der Erstellung nicht geändert werden kann."
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "New Membership Fee Type"
+msgstr "Neue Mitgliedsbeitragsart"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "New amount"
+msgstr "Neuer Betrag"
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "No cycle"
+msgstr "Kein Zyklus"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "No cycles"
+msgstr "Keine Zyklen"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
+msgstr "Keine Mitgliedsbeitragszylen gefunden. Zyklen werden automatisch generiert, wenn eine Mitgliedsbeitragsart zugewiesen wird."
+
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No membership fee type assigned"
+msgstr "Keine Mitgliedsbeitragsart zugewiesen"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "No status"
+msgstr "Kein Status"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Please confirm the amount change first"
+msgstr "Bitte bestätigen Sie zuerst die Betragsänderung"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Regenerate Cycles"
+msgstr "Zyklen regenerieren"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Regenerating..."
+msgstr "Regeneriere..."
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Save Membership Fee Type"
+msgstr "Mitgliedsbeitragsart speichern"
+
+#: lib/mv_web/live/member_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
+msgstr "Wählen Sie eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder können nur zwischen Arten mit demselben Intervall wechseln."
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Select interval"
+msgstr "Intervall auswählen"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Type"
+msgstr "Art"
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Use this form to manage membership fee types in your database."
+msgstr "Verwenden Sie dieses Formular, um Mitgliedsbeitragsarten in Ihrer Datenbank zu verwalten."
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
+msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wählen Sie eine Mitgliedsbeitragsart mit demselben Intervall."
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "A cycle for this period already exists"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "All cycles deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Click to edit amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Create"
+msgstr "erstellt"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Create Cycle"
+msgstr "Aktueller Zyklus"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Create a new cycle manually"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Cycle Period"
+msgstr "Zyklus"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Cycle created successfully"
+msgstr "Zyklen erfolgreich regeneriert"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete All"
+msgstr "Löschen"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete All Cycles"
+msgstr "Alle Zyklen löschen"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete all cycles"
+msgstr "Zyklus löschen"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete cycle"
+msgstr "Zyklus löschen"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Invalid date format"
+msgstr "Ungültiges Betragsformat"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Payment Interval"
+msgstr "Zahlungsfilter"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "The cycle period will be calculated based on this date and the interval."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "This action cannot be undone."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Type '%{confirmation}' to confirm"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Warning"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "You are about to delete all %{count} cycles for this member."
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Current Cycle Payment Status"
+msgstr "Aktueller Zyklus Zahlungsstatus"
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Last Cycle Payment Status"
+msgstr "Letzter Zyklus Zahlungsstatus"
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Delete membership fee type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit membership fee type"
+msgstr "Mitgliedsbeitragsart bearbeiten"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Confirmation text does not match"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "No cycles to delete"
+msgstr "Keine Zyklen"
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Not set"
+msgstr "Nicht gesetzt"
+
+#~ #: lib/mv_web/live/components/payment_filter_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "All payment statuses"
+#~ msgstr "Jeder Zahlungs-Zustand"
+
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
@@ -1432,47 +1841,37 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
#~ msgid "Configure global settings for membership contributions."
#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Contribution"
+#~ msgstr "Beitrag"
+
#~ #: lib/mv_web/components/layouts/navbar.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Contribution Settings"
#~ msgstr "Beitragseinstellungen"
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Contribution start"
-#~ msgstr "Beitragsbeginn"
-
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy emails"
#~ msgstr "E-Mails kopieren"
-#~ #: lib/mv_web/live/member_live/form.ex
-#~ #: lib/mv_web/live/member_live/show.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Custom Field Values"
-#~ msgstr "Benutzerdefinierte Feldwerte"
-
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type"
#~ msgstr "Standard-Beitragsart"
-#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
-#~ msgid "Example: Member Contribution View"
-#~ msgstr "Beispiel: Ansicht Mitgliedsbeiträge"
+#~ msgid "Edit amount"
+#~ msgstr "Betrag bearbeiten"
-#~ #: lib/mv_web/live/membership_fee_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Failed to save settings. Please check the errors below."
-#~ msgstr "Einstellungen konnten nicht gespeichert werden. Bitte prüfen Sie die Fehler unten."
-
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Generated periods"
-#~ msgstr "Generierte Zyklen"
+#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Failed to delete some cycles: %{errors}"
+#~ msgstr "Konnte Feld nicht löschen: %{error}"
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
@@ -1484,58 +1883,89 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
#~ msgid "Include joining period"
#~ msgstr "Beitrittsdatum einbeziehen"
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Monthly Interval - Joining Period Included"
-#~ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
-
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr "Benutzerdefiniertes Feld speichern"
-#~ #: lib/mv_web/live/user_live/form.ex
-#~ #: lib/mv_web/live/user_live/show.ex
+#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
-#~ msgid "Not set"
-#~ msgstr "Nicht gesetzt"
+#~ msgid "Not paid"
+#~ msgstr "Nicht bezahlt"
+
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Payment Cycle"
+#~ msgstr "Zahlungszyklus"
+
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Pending"
+#~ msgstr "Ausstehend"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
-#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
-#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
-#~ msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt."
+#~ msgid "Show Last/Current Cycle Payment Status"
+#~ msgstr ""
-#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
-#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
-#~ msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden."
+#~ msgid "Show current cycle"
+#~ msgstr "Aktuellen Zyklus anzeigen"
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Show last completed cycle"
+#~ msgstr "Letzten abgeschlossenen Zyklus anzeigen"
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Switch to current cycle"
+#~ msgstr "Zum aktuellen Zyklus wechseln"
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Switch to last completed cycle"
+#~ msgstr "Zum letzten abgeschlossenen Zyklus wechseln"
+
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "This data is for demonstration purposes only (mockup)."
+#~ msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Unpaid in current cycle"
+#~ msgstr "Unbezahlt im aktuellen Zyklus"
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Unpaid in last cycle"
+#~ msgstr "Unbezahlt im letzten Zyklus"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View Example Member"
#~ msgstr "Beispielmitglied anzeigen"
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "When active: Members pay from the period of their joining."
-#~ msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
-
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "When inactive: Members pay from the next full period after joining."
-#~ msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
-
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Yearly Interval - Joining Period Excluded"
-#~ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
-
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Yearly Interval - Joining Period Included"
#~ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
+
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "monthly"
+#~ msgstr "monatlich"
+
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "yearly"
+#~ msgstr "jährlich"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index e2bbf32..be36eb6 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -18,6 +18,7 @@ msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@@ -38,6 +39,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@@ -142,10 +144,9 @@ msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/translations/member_fields.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@@ -171,6 +172,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
@@ -184,7 +186,6 @@ msgid "Street"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -197,9 +198,9 @@ msgid "Show Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@@ -257,6 +258,8 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@@ -269,6 +272,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@@ -303,6 +307,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
@@ -310,6 +315,8 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@@ -772,11 +779,13 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@@ -786,11 +795,6 @@ msgstr ""
msgid "Filter by payment status"
msgstr ""
-#: lib/mv_web/live/components/payment_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Not paid"
-msgstr ""
-
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
@@ -808,7 +812,6 @@ msgid "Back"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Coming soon"
msgstr ""
@@ -819,40 +822,21 @@ msgstr ""
msgid "Contact Data"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Contribution"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Nr."
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Payment Cycle"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Pending"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -867,27 +851,11 @@ msgid "Phone"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Save"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "This data is for demonstration purposes only (mockup)."
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "monthly"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#, elixir-autogen, elixir-format
-msgid "yearly"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Create Member"
@@ -907,6 +875,9 @@ msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Amount"
msgstr ""
@@ -917,6 +888,7 @@ msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
@@ -936,7 +908,6 @@ msgstr ""
msgid "Contribution Start"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
@@ -968,6 +939,7 @@ msgid "Current"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Deletion"
msgstr ""
@@ -983,6 +955,7 @@ msgid "Family"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
@@ -992,9 +965,11 @@ msgstr ""
msgid "Global Settings"
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr ""
@@ -1012,6 +987,9 @@ msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Interval"
msgstr ""
@@ -1081,9 +1059,11 @@ msgstr ""
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Monthly"
msgstr ""
@@ -1094,6 +1074,7 @@ msgid "Monthly fee for students and trainees"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
@@ -1109,6 +1090,7 @@ msgid "No fee for honorary members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr ""
@@ -1129,9 +1111,11 @@ msgstr ""
msgid "Preview Mockup"
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr ""
@@ -1169,6 +1153,7 @@ msgid "Standard membership fee for regular members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""
@@ -1189,6 +1174,9 @@ msgid "Suspend"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
@@ -1209,7 +1197,11 @@ msgstr ""
msgid "Total Contributions"
msgstr ""
+#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
@@ -1219,9 +1211,11 @@ msgstr ""
msgid "Why are not all contribution types shown?"
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Yearly"
msgstr ""
@@ -1242,6 +1236,7 @@ msgid "Last name"
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "None"
msgstr ""
@@ -1302,6 +1297,7 @@ msgstr ""
msgid "Value Type"
msgstr ""
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/translations/field_types.ex
#, elixir-autogen, elixir-format
msgid "Date"
@@ -1327,11 +1323,6 @@ msgstr ""
msgid "Yes/No-Selection"
msgstr ""
-#: lib/mv_web/live/components/payment_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "All payment statuses"
-msgstr ""
-
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy email addresses"
@@ -1347,6 +1338,11 @@ msgstr ""
msgid "Save Custom Field Value"
msgstr ""
+#: lib/mv_web/components/core_components.ex
+#, elixir-autogen, elixir-format
+msgid "This field is required"
+msgstr ""
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings for membership fees."
@@ -1422,3 +1418,411 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "About Membership Fee Types"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Already paid cycles will remain with the old amount."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "An error occurred"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Are you sure you want to delete this cycle?"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Cannot delete - %{count} member(s) assigned"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Change Amount?"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Changing the amount will affect %{count} member(s)."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Confirm Change"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Current Cycle"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Current amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle amount updated"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle status updated"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycles regenerated successfully"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete Cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Cycle Amount"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to update cycle status: %{errors}"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Future unpaid cycles will be regenerated with the new amount."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Generate cycles from the last existing cycle to today"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Interval cannot be changed after creation."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid amount format"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Last Cycle"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Manage membership fee types for membership fees."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Mark as paid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Mark as suspended"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Mark as unpaid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee Status"
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/components/layouts/navbar.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fee Types"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership Fees"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type removed"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type saved successfully"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type updated. Cycles regenerated."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "New Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "New amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "No cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "No cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No membership fee type assigned"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "No status"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Please confirm the amount change first"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Regenerate Cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Regenerating..."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Save Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Select interval"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Use this form to manage membership fee types in your database."
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "A cycle for this period already exists"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "All cycles deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Click to edit amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Create"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Create Cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Create a new cycle manually"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle Period"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle created successfully"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete All"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete All Cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete all cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid date format"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Payment Interval"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "The cycle period will be calculated based on this date and the interval."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "This action cannot be undone."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Type '%{confirmation}' to confirm"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Warning"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "You are about to delete all %{count} cycles for this member."
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Current Cycle Payment Status"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Last Cycle Payment Status"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Delete membership fee type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Edit membership fee type"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Confirmation text does not match"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No cycles to delete"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Not set"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index d3ee646..9c2dc9a 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -18,6 +18,7 @@ msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@@ -38,6 +39,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@@ -142,10 +144,9 @@ msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/translations/member_fields.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@@ -171,6 +172,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
@@ -184,7 +186,6 @@ msgid "Street"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -197,9 +198,9 @@ msgid "Show Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@@ -257,6 +258,8 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@@ -269,6 +272,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@@ -303,6 +307,7 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
@@ -310,6 +315,8 @@ msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@@ -772,11 +779,13 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@@ -786,11 +795,6 @@ msgstr ""
msgid "Filter by payment status"
msgstr ""
-#: lib/mv_web/live/components/payment_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Not paid"
-msgstr ""
-
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
@@ -808,7 +812,6 @@ msgid "Back"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Coming soon"
msgstr ""
@@ -819,40 +822,21 @@ msgstr ""
msgid "Contact Data"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Contribution"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Nr."
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Payment Cycle"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Pending"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -867,27 +851,11 @@ msgid "Phone"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "This data is for demonstration purposes only (mockup)."
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "monthly"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#, elixir-autogen, elixir-format
-msgid "yearly"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Create Member"
@@ -907,6 +875,9 @@ msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Amount"
msgstr ""
@@ -917,6 +888,7 @@ msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
@@ -936,7 +908,6 @@ msgstr ""
msgid "Contribution Start"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
@@ -968,6 +939,7 @@ msgid "Current"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
msgstr ""
@@ -983,6 +955,7 @@ msgid "Family"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
@@ -992,9 +965,11 @@ msgstr ""
msgid "Global Settings"
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Half-yearly"
msgstr ""
@@ -1012,6 +987,9 @@ msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Interval"
msgstr ""
@@ -1081,9 +1059,11 @@ msgstr ""
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Monthly"
msgstr ""
@@ -1094,6 +1074,7 @@ msgid "Monthly fee for students and trainees"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
@@ -1109,6 +1090,7 @@ msgid "No fee for honorary members"
msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
msgstr ""
@@ -1129,9 +1111,11 @@ msgstr ""
msgid "Preview Mockup"
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Quarterly"
msgstr ""
@@ -1169,6 +1153,7 @@ msgid "Standard membership fee for regular members"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""
@@ -1189,6 +1174,9 @@ msgid "Suspend"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
@@ -1209,7 +1197,11 @@ msgstr ""
msgid "Total Contributions"
msgstr ""
+#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
@@ -1219,9 +1211,11 @@ msgstr ""
msgid "Why are not all contribution types shown?"
msgstr ""
+#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
+#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Yearly"
msgstr ""
@@ -1242,6 +1236,7 @@ msgid "Last name"
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "None"
msgstr ""
@@ -1302,6 +1297,7 @@ msgstr ""
msgid "Value Type"
msgstr ""
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/translations/field_types.ex
#, elixir-autogen, elixir-format
msgid "Date"
@@ -1327,11 +1323,6 @@ msgstr ""
msgid "Yes/No-Selection"
msgstr ""
-#: lib/mv_web/live/components/payment_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "All payment statuses"
-msgstr ""
-
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
@@ -1347,6 +1338,11 @@ msgstr ""
msgid "Save Custom Field Value"
msgstr ""
+#: lib/mv_web/components/core_components.ex
+#, elixir-autogen, elixir-format
+msgid "This field is required"
+msgstr ""
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings for membership fees."
@@ -1423,6 +1419,419 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "About Membership Fee Types"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Already paid cycles will remain with the old amount."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "An error occurred"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Are you sure you want to delete this cycle?"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Cannot delete - %{count} member(s) assigned"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Change Amount?"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Changing the amount will affect %{count} member(s)."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Confirm Change"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Current Cycle"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Current amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle amount updated"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycle status updated"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Cycles regenerated successfully"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete Cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Cycle Amount"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to update cycle status: %{errors}"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Future unpaid cycles will be regenerated with the new amount."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Generate cycles from the last existing cycle to today"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Interval cannot be changed after creation."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Invalid amount format"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Last Cycle"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Manage membership fee types for membership fees."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Mark as paid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Mark as suspended"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Mark as unpaid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership Fee"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership Fee Status"
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/components/layouts/navbar.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership Fee Types"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership Fees"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership fee type deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership fee type removed"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type saved successfully"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership fee type updated. Cycles regenerated."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "New Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "New amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "No cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "No cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "No membership fee type assigned"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "No status"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Please confirm the amount change first"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Regenerate Cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Regenerating..."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Save Membership Fee Type"
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Select interval"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Use this form to manage membership fee types in your database."
+msgstr ""
+
+#: lib/mv_web/live/member_live/form.ex
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "A cycle for this period already exists"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "All cycles deleted"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Click to edit amount"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Create"
+msgstr "created"
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Create Cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Create a new cycle manually"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Cycle Period"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Cycle created successfully"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete All"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete All Cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete all cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete cycle"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Invalid date format"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Payment Interval"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "The cycle period will be calculated based on this date and the interval."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "This action cannot be undone."
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Type '%{confirmation}' to confirm"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Warning"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "You are about to delete all %{count} cycles for this member."
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Current Cycle Payment Status"
+msgstr "Current Cycle Payment Status"
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Last Cycle Payment Status"
+msgstr "Last Cycle Payment Status"
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Delete membership fee type"
+msgstr ""
+
+#: lib/mv_web/live/membership_fee_type_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit membership fee type"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format
+msgid "Confirmation text does not match"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "No cycles to delete"
+msgstr ""
+
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Not set"
+msgstr ""
+
+#~ #: lib/mv_web/live/components/payment_filter_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "All payment statuses"
+#~ msgstr ""
+
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
@@ -1433,6 +1842,12 @@ msgstr ""
#~ msgid "Configure global settings for membership contributions."
#~ msgstr ""
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Contribution"
+#~ msgstr ""
+
#~ #: lib/mv_web/components/layouts/navbar.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
@@ -1449,28 +1864,34 @@ msgstr ""
#~ msgid "Copy emails"
#~ msgstr ""
-#~ #: lib/mv_web/live/member_live/form.ex
-#~ #: lib/mv_web/live/member_live/show.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Custom Field Values"
-#~ msgstr ""
-
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type"
#~ msgstr ""
+#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Edit amount"
+#~ msgstr ""
+
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Example: Member Contribution View"
#~ msgstr ""
+#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Failed to delete some cycles: %{errors}"
+#~ msgstr ""
+
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr ""
-#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/user_live/index.html.heex
+#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Generated periods"
#~ msgstr ""
@@ -1485,19 +1906,24 @@ msgstr ""
#~ msgid "Include joining period"
#~ msgstr ""
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Monthly Interval - Joining Period Included"
-#~ msgstr ""
-
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr ""
-#~ #: lib/mv_web/live/user_live/show.ex
+#~ #: lib/mv_web/live/components/payment_filter_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Not paid"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Not set"
+#~ msgid "Payment Cycle"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Pending"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
@@ -1505,14 +1931,45 @@ msgstr ""
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr ""
-#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
-#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
+#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
-#~ #: lib/mv_web/live/contribution_settings_live.ex
+#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
-#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
+#~ msgid "Show current cycle"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Show last completed cycle"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Switch to current cycle"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Switch to last completed cycle"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "This data is for demonstration purposes only (mockup)."
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Unpaid in current cycle"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Unpaid in last cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
@@ -1520,22 +1977,18 @@ msgstr ""
#~ msgid "View Example Member"
#~ msgstr ""
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "When active: Members pay from the period of their joining."
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "When inactive: Members pay from the next full period after joining."
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/contribution_settings_live.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Yearly Interval - Joining Period Excluded"
-#~ msgstr ""
-
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Yearly Interval - Joining Period Included"
#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #: lib/mv_web/live/member_live/show.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "monthly"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/form.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "yearly"
+#~ msgstr ""
diff --git a/priv/repo/migrations/20251218113900_remove_paid_from_members.exs b/priv/repo/migrations/20251218113900_remove_paid_from_members.exs
new file mode 100644
index 0000000..3722137
--- /dev/null
+++ b/priv/repo/migrations/20251218113900_remove_paid_from_members.exs
@@ -0,0 +1,21 @@
+defmodule Mv.Repo.Migrations.RemovePaidFromMembers do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ alter table(:members) do
+ remove :paid
+ end
+ end
+
+ def down do
+ alter table(:members) do
+ add :paid, :boolean
+ end
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 2e8694d..fb102f4 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -6,6 +6,7 @@
alias Mv.Membership
alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType
+alias Mv.MembershipFees.CycleGenerator
# Create example membership fee types
for fee_type_attrs <- [
@@ -127,59 +128,153 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
+# Load all membership fee types for assignment
+# Sort by name to ensure deterministic order
+all_fee_types =
+ MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!()
+ |> Enum.to_list()
+
# Create sample members for testing - use upsert to prevent duplicates
-for member_attrs <- [
- %{
- first_name: "Hans",
- last_name: "Müller",
- email: "hans.mueller@example.de",
- join_date: ~D[2023-01-15],
- paid: true,
- phone_number: "+49301234567",
- city: "München",
- street: "Hauptstraße",
- house_number: "42",
- postal_code: "80331"
- },
- %{
- first_name: "Greta",
- last_name: "Schmidt",
- email: "greta.schmidt@example.de",
- join_date: ~D[2023-02-01],
- paid: false,
- phone_number: "+49309876543",
- city: "Hamburg",
- street: "Lindenstraße",
- house_number: "17",
- postal_code: "20095",
- notes: "Interessiert an Fortgeschrittenen-Kursen"
- },
- %{
- first_name: "Friedrich",
- last_name: "Wagner",
- email: "friedrich.wagner@example.de",
- join_date: ~D[2022-11-10],
- paid: true,
- phone_number: "+49301122334",
- city: "Berlin",
- street: "Kastanienallee",
- house_number: "8"
- },
- %{
- first_name: "Marianne",
- last_name: "Wagner",
- email: "marianne.wagner@example.de",
- join_date: ~D[2022-11-10],
- paid: true,
- phone_number: "+49301122334",
- city: "Berlin",
- street: "Kastanienallee",
- house_number: "8"
- }
- ] do
+# Member 1: Hans - All cycles paid
+# Member 2: Greta - All cycles unpaid
+# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
+# Member 4: Marianne - No membership fee type
+member_attrs_list = [
+ %{
+ first_name: "Hans",
+ last_name: "Müller",
+ email: "hans.mueller@example.de",
+ join_date: ~D[2023-01-15],
+ phone_number: "+49301234567",
+ city: "München",
+ street: "Hauptstraße",
+ house_number: "42",
+ postal_code: "80331",
+ membership_fee_type_id: Enum.at(all_fee_types, 0).id,
+ cycle_status: :all_paid
+ },
+ %{
+ first_name: "Greta",
+ last_name: "Schmidt",
+ email: "greta.schmidt@example.de",
+ join_date: ~D[2023-02-01],
+ phone_number: "+49309876543",
+ city: "Hamburg",
+ street: "Lindenstraße",
+ house_number: "17",
+ postal_code: "20095",
+ notes: "Interessiert an Fortgeschrittenen-Kursen",
+ membership_fee_type_id: Enum.at(all_fee_types, 1).id,
+ cycle_status: :all_unpaid
+ },
+ %{
+ first_name: "Friedrich",
+ last_name: "Wagner",
+ email: "friedrich.wagner@example.de",
+ join_date: ~D[2022-11-10],
+ phone_number: "+49301122334",
+ city: "Berlin",
+ street: "Kastanienallee",
+ house_number: "8",
+ membership_fee_type_id: Enum.at(all_fee_types, 2).id,
+ cycle_status: :mixed
+ },
+ %{
+ first_name: "Marianne",
+ last_name: "Wagner",
+ email: "marianne.wagner@example.de",
+ join_date: ~D[2022-11-10],
+ phone_number: "+49301122334",
+ city: "Berlin",
+ street: "Kastanienallee",
+ house_number: "8"
+ # No membership_fee_type_id - member without fee type
+ }
+]
+
+# Create members and generate cycles
+Enum.each(member_attrs_list, fn member_attrs ->
+ cycle_status = Map.get(member_attrs, :cycle_status)
+ member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
+
# Use upsert to prevent duplicates based on email
- Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email)
-end
+ # First create/update member without membership_fee_type_id to avoid overwriting existing assignments
+ member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id)
+
+ member =
+ Membership.create_member!(member_attrs_without_fee_type,
+ upsert?: true,
+ upsert_identity: :unique_email
+ )
+
+ # Only set membership_fee_type_id if member doesn't have one yet (idempotent)
+ final_member =
+ if is_nil(member.membership_fee_type_id) and
+ Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
+ member
+ |> Ash.Changeset.for_update(:update_member, %{
+ membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
+ })
+ |> Ash.update!()
+ else
+ member
+ end
+
+ # Generate cycles if member has a fee type
+ if final_member.membership_fee_type_id do
+ # Load member with cycles to check if they already exist
+ member_with_cycles =
+ final_member
+ |> Ash.load!(:membership_fee_cycles)
+
+ # Only generate if no cycles exist yet (to avoid duplicates on re-run)
+ cycles =
+ if Enum.empty?(member_with_cycles.membership_fee_cycles) do
+ # Generate cycles
+ {:ok, new_cycles, _notifications} =
+ CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
+
+ new_cycles
+ else
+ # Use existing cycles
+ member_with_cycles.membership_fee_cycles
+ end
+
+ # Set cycle statuses based on member type
+ if cycle_status do
+ cycles
+ |> Enum.sort_by(& &1.cycle_start, Date)
+ |> Enum.with_index()
+ |> Enum.each(fn {cycle, index} ->
+ status =
+ case cycle_status do
+ :all_paid ->
+ :paid
+
+ :all_unpaid ->
+ :unpaid
+
+ :mixed ->
+ # Mix: first paid, second unpaid, third suspended, then repeat
+ case rem(index, 3) do
+ 0 -> :paid
+ 1 -> :unpaid
+ 2 -> :suspended
+ end
+ end
+
+ # Only update if status is different
+ if cycle.status != status do
+ cycle
+ |> Ash.Changeset.for_update(:update, %{status: status})
+ |> Ash.update!()
+ end
+ end)
+ end
+ end
+end)
# Create additional users for user-member linking examples
additional_users = [
@@ -204,7 +299,6 @@ linked_members = [
last_name: "Weber",
email: "maria.weber@example.de",
join_date: ~D[2023-03-15],
- paid: true,
phone_number: "+49301357924",
city: "Frankfurt",
street: "Goetheplatz",
@@ -219,7 +313,6 @@ linked_members = [
last_name: "Klein",
email: "thomas.klein@example.de",
join_date: ~D[2023-04-01],
- paid: false,
phone_number: "+49302468135",
city: "Köln",
street: "Rheinstraße",
@@ -232,24 +325,84 @@ linked_members = [
]
# Create the linked members - use upsert to prevent duplicates
-Enum.each(linked_members, fn member_attrs ->
+# Assign fee types to linked members using round-robin
+# Continue from where we left off with the previous members
+Enum.with_index(linked_members)
+|> Enum.each(fn {member_attrs, index} ->
user = member_attrs.user
member_attrs_without_user = Map.delete(member_attrs, :user)
+ # Use upsert to prevent duplicates based on email
+ # First create/update member without membership_fee_type_id to avoid overwriting existing assignments
+ member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id)
+
# Check if user already has a member
- if user.member_id == nil do
- # User is free, create member and link - use upsert to prevent duplicates
- Membership.create_member!(
- Map.put(member_attrs_without_user, :user, %{id: user.id}),
- upsert?: true,
- upsert_identity: :unique_email
- )
- else
- # User already has a member, just create the member without linking - use upsert to prevent duplicates
- Membership.create_member!(member_attrs_without_user,
- upsert?: true,
- upsert_identity: :unique_email
- )
+ member =
+ if user.member_id == nil do
+ # User is free, create member and link - use upsert to prevent duplicates
+ Membership.create_member!(
+ Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
+ upsert?: true,
+ upsert_identity: :unique_email
+ )
+ else
+ # User already has a member, just create the member without linking - use upsert to prevent duplicates
+ Membership.create_member!(member_attrs_without_fee_type,
+ upsert?: true,
+ upsert_identity: :unique_email
+ )
+ end
+
+ # Only set membership_fee_type_id if member doesn't have one yet (idempotent)
+ final_member =
+ if is_nil(member.membership_fee_type_id) do
+ # Assign deterministically using round-robin
+ # Start from where previous members ended (3 members before this)
+ fee_type_index = rem(3 + index, length(all_fee_types))
+ fee_type = Enum.at(all_fee_types, fee_type_index)
+
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+ else
+ member
+ end
+
+ # Generate cycles for linked members
+ if final_member.membership_fee_type_id do
+ # Load member with cycles to check if they already exist
+ member_with_cycles =
+ final_member
+ |> Ash.load!(:membership_fee_cycles)
+
+ # Only generate if no cycles exist yet (to avoid duplicates on re-run)
+ cycles =
+ if Enum.empty?(member_with_cycles.membership_fee_cycles) do
+ # Generate cycles
+ {:ok, new_cycles, _notifications} =
+ CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
+
+ new_cycles
+ else
+ # Use existing cycles
+ member_with_cycles.membership_fee_cycles
+ end
+
+ # Set some cycles to paid for linked members (mixed status)
+ cycles
+ |> Enum.sort_by(& &1.cycle_start, Date)
+ |> Enum.with_index()
+ |> Enum.each(fn {cycle, index} ->
+ # Every other cycle is paid, rest unpaid
+ status = if rem(index, 2) == 0, do: :paid, else: :unpaid
+
+ # Only update if status is different
+ if cycle.status != status do
+ cycle
+ |> Ash.Changeset.for_update(:update, %{status: status})
+ |> Ash.update!()
+ end
+ end)
end
end)
diff --git a/priv/resource_snapshots/repo/custom_fields/20251218113900.json b/priv/resource_snapshots/repo/custom_fields/20251218113900.json
new file mode 100644
index 0000000..b8fee81
--- /dev/null
+++ b/priv/resource_snapshots/repo/custom_fields/20251218113900.json
@@ -0,0 +1,132 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "slug",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "value_type",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "description",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "required",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": false,
+ "default": "true",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "show_in_overview",
+ "type": "boolean"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "6FEA699A67D34CFBA261DA8316AB711F6853C4F953D42C5D7940B22D17699B2E",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_name_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "name"
+ }
+ ],
+ "name": "unique_name",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_slug_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "slug"
+ }
+ ],
+ "name": "unique_slug",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "custom_fields"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/members/20251218113900.json b/priv/resource_snapshots/repo/members/20251218113900.json
new file mode 100644
index 0000000..96ad143
--- /dev/null
+++ b/priv/resource_snapshots/repo/members/20251218113900.json
@@ -0,0 +1,233 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v7()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "first_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "last_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "phone_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "exit_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "notes",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "city",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "street",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "house_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "postal_code",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "search_vector",
+ "type": "tsvector"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "membership_fee_start_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "members_membership_fee_type_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "membership_fee_types"
+ },
+ "scale": null,
+ "size": null,
+ "source": "membership_fee_type_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "E18E4B404581EFF050F85E895FAE986B79DB62C9E1611164C92B46B954C371C1",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "members_unique_email_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "email"
+ }
+ ],
+ "name": "unique_email",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "members"
+}
\ No newline at end of file
diff --git a/test/membership/member_required_custom_fields_test.exs b/test/membership/member_required_custom_fields_test.exs
new file mode 100644
index 0000000..ec8ebe3
--- /dev/null
+++ b/test/membership/member_required_custom_fields_test.exs
@@ -0,0 +1,635 @@
+defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
+ @moduledoc """
+ Tests for required custom fields validation.
+
+ Tests cover:
+ - Member creation without required custom field → error
+ - Member creation with empty required custom field (nil/empty string) → error
+ - Member creation with valid required custom field → success
+ - Member update: removing a required custom field value → error
+ - Boolean required custom field: false is valid, nil is invalid
+ """
+ use Mv.DataCase, async: true
+
+ alias Mv.Membership
+
+ setup do
+ # Create required custom fields for different types
+ {:ok, required_string_field} =
+ Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "required_string",
+ value_type: :string,
+ required: true
+ })
+ |> Ash.create()
+
+ {:ok, required_integer_field} =
+ Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "required_integer",
+ value_type: :integer,
+ required: true
+ })
+ |> Ash.create()
+
+ {:ok, required_boolean_field} =
+ Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "required_boolean",
+ value_type: :boolean,
+ required: true
+ })
+ |> Ash.create()
+
+ {:ok, required_date_field} =
+ Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "required_date",
+ value_type: :date,
+ required: true
+ })
+ |> Ash.create()
+
+ {:ok, required_email_field} =
+ Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "required_email",
+ value_type: :email,
+ required: true
+ })
+ |> Ash.create()
+
+ {:ok, optional_field} =
+ Membership.CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "optional_string",
+ value_type: :string,
+ required: false
+ })
+ |> Ash.create()
+
+ %{
+ required_string_field: required_string_field,
+ required_integer_field: required_integer_field,
+ required_boolean_field: required_boolean_field,
+ required_date_field: required_date_field,
+ required_email_field: required_email_field,
+ optional_field: optional_field
+ }
+ end
+
+ # Helper function to create all required custom fields with valid default values
+ defp all_required_custom_fields_with_defaults(%{
+ required_string_field: string_field,
+ required_integer_field: integer_field,
+ required_boolean_field: boolean_field,
+ required_date_field: date_field,
+ required_email_field: email_field
+ }) do
+ [
+ %{
+ "custom_field_id" => string_field.id,
+ "value" => %{"_union_type" => "string", "_union_value" => "default"}
+ },
+ %{
+ "custom_field_id" => integer_field.id,
+ "value" => %{"_union_type" => "integer", "_union_value" => 0}
+ },
+ %{
+ "custom_field_id" => boolean_field.id,
+ "value" => %{"_union_type" => "boolean", "_union_value" => false}
+ },
+ %{
+ "custom_field_id" => date_field.id,
+ "value" => %{"_union_type" => "date", "_union_value" => ~D[2020-01-01]}
+ },
+ %{
+ "custom_field_id" => email_field.id,
+ "value" => %{"_union_type" => "email", "_union_value" => "test@example.com"}
+ }
+ ]
+ end
+
+ describe "create_member with required custom fields" do
+ @valid_attrs %{
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com"
+ }
+
+ test "fails when required custom field is missing", %{required_string_field: field} do
+ attrs = Map.put(@valid_attrs, :custom_field_values, [])
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when required string custom field has nil value",
+ %{
+ required_string_field: field
+ } = context do
+ # Start with all required fields having valid values
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => nil}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when required string custom field has empty string value",
+ %{
+ required_string_field: field
+ } = context do
+ # Start with all required fields having valid values
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when required string custom field has whitespace-only value",
+ %{
+ required_string_field: field
+ } = context do
+ # Start with all required fields having valid values
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => " "}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "succeeds when required string custom field has valid value",
+ %{
+ required_string_field: field
+ } = context do
+ # Start with all required fields having valid values, then update the string field
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test value"}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "fails when required integer custom field has nil value",
+ %{
+ required_integer_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => nil}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when required integer custom field has empty string value",
+ %{
+ required_integer_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => ""}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "succeeds when required integer custom field has zero value",
+ %{
+ required_integer_field: _field
+ } = context do
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "succeeds when required integer custom field has positive value",
+ %{
+ required_integer_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "fails when required boolean custom field has nil value",
+ %{
+ required_boolean_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => nil}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "succeeds when required boolean custom field has false value",
+ %{
+ required_boolean_field: _field
+ } = context do
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "succeeds when required boolean custom field has true value",
+ %{
+ required_boolean_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "fails when required date custom field has nil value",
+ %{
+ required_date_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "date", "_union_value" => nil}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when required date custom field has empty string value",
+ %{
+ required_date_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "date", "_union_value" => ""}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "succeeds when required date custom field has valid date value",
+ %{
+ required_date_field: _field
+ } = context do
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "fails when required email custom field has nil value",
+ %{
+ required_email_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "email", "_union_value" => nil}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when required email custom field has empty string value",
+ %{
+ required_email_field: field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "email", "_union_value" => ""}}
+ else
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "succeeds when required email custom field has valid email value",
+ %{
+ required_email_field: _field
+ } = context do
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "succeeds when multiple required custom fields are provided",
+ %{
+ required_string_field: string_field,
+ required_integer_field: integer_field,
+ required_boolean_field: boolean_field
+ } = context do
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ cond do
+ cfv["custom_field_id"] == string_field.id ->
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
+
+ cfv["custom_field_id"] == integer_field.id ->
+ %{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
+
+ cfv["custom_field_id"] == boolean_field.id ->
+ %{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
+
+ true ->
+ cfv
+ end
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "fails when one of multiple required custom fields is missing",
+ %{
+ required_string_field: string_field,
+ required_integer_field: integer_field
+ } = context do
+ # Provide only string field, missing integer, boolean, and date
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.filter(fn cfv ->
+ cfv["custom_field_id"] == string_field.id
+ end)
+ |> Enum.map(fn cfv ->
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
+ end)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ integer_field.name
+ end
+
+ test "succeeds when optional custom field is missing", %{} = context do
+ # Provide all required fields, but no optional field
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+
+ test "succeeds when optional custom field has nil value",
+ %{optional_field: field} = context do
+ # Provide all required fields plus optional field with nil
+ custom_field_values =
+ all_required_custom_fields_with_defaults(context) ++
+ [
+ %{
+ "custom_field_id" => field.id,
+ "value" => %{"_union_type" => "string", "_union_value" => nil}
+ }
+ ]
+
+ attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
+
+ assert {:ok, _member} = Membership.create_member(attrs)
+ end
+ end
+
+ describe "update_member with required custom fields" do
+ test "fails when removing a required custom field value",
+ %{
+ required_string_field: field
+ } = context do
+ # Create member with all required custom fields
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com",
+ custom_field_values: custom_field_values
+ })
+
+ # Try to update without the required custom field
+ assert {:error, %Ash.Error.Invalid{errors: errors}} =
+ Membership.update_member(member, %{custom_field_values: []})
+
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "fails when setting required custom field value to empty",
+ %{
+ required_string_field: field
+ } = context do
+ # Create member with all required custom fields
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com",
+ custom_field_values: custom_field_values
+ })
+
+ # Try to update with empty value for the string field
+ updated_custom_field_values =
+ all_required_custom_fields_with_defaults(context)
+ |> Enum.map(fn cfv ->
+ if cfv["custom_field_id"] == field.id do
+ %{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
+ else
+ cfv
+ end
+ end)
+
+ assert {:error, %Ash.Error.Invalid{errors: errors}} =
+ Membership.update_member(member, %{
+ custom_field_values: updated_custom_field_values
+ })
+
+ assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
+ assert error_message(errors, :custom_field_values) =~ field.name
+ end
+
+ test "succeeds when updating required custom field to valid value",
+ %{
+ required_string_field: field
+ } = context do
+ # Create member with all required custom fields
+ custom_field_values = all_required_custom_fields_with_defaults(context)
+
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com",
+ custom_field_values: custom_field_values
+ })
+
+ # Load existing custom field values to get their IDs
+ {:ok, member_with_cfvs} = Ash.load(member, :custom_field_values)
+
+ # Update with new valid value for the string field, using existing IDs
+ updated_custom_field_values =
+ member_with_cfvs.custom_field_values
+ |> Enum.map(fn cfv ->
+ if cfv.custom_field_id == field.id do
+ %{
+ "id" => cfv.id,
+ "custom_field_id" => cfv.custom_field_id,
+ "value" => %{"_union_type" => "string", "_union_value" => "new value"}
+ }
+ else
+ # Keep other fields as they are
+ value_type = Atom.to_string(cfv.value.type)
+ actual_value = cfv.value.value
+
+ %{
+ "id" => cfv.id,
+ "custom_field_id" => cfv.custom_field_id,
+ "value" => %{"_union_type" => value_type, "_union_value" => actual_value}
+ }
+ end
+ end)
+
+ assert {:ok, _updated_member} =
+ Membership.update_member(member, %{
+ custom_field_values: updated_custom_field_values
+ })
+ end
+ end
+
+ # Helper function for error evaluation
+ defp error_message(errors, field) do
+ errors
+ |> Enum.filter(fn err -> Map.get(err, :field) == field end)
+ |> Enum.map_join(" ", &Map.get(&1, :message, ""))
+ end
+end
diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs
index 1bf594a..1c4beb1 100644
--- a/test/membership/member_test.exs
+++ b/test/membership/member_test.exs
@@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
- paid: true,
email: "john@example.com",
phone_number: "+49123456789",
join_date: ~D[2020-01-01],
@@ -42,14 +41,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email"
end
- test "Paid is optional but must be boolean if specified" do
- attrs = Map.put(@valid_attrs, :paid, nil)
- attrs2 = Map.put(@valid_attrs, :paid, "yes")
- assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid))
- assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2)
- assert error_message(errors, :paid) =~ "is invalid"
- end
-
test "Phone number is optional but must have a valid format if specified" do
attrs = Map.put(@valid_attrs, :phone_number, "abc")
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
@@ -58,12 +49,12 @@ defmodule Mv.Membership.MemberTest do
assert {:ok, _member} = Membership.create_member(attrs2)
end
- test "Join date is optional but must not be in the future" do
+ test "Join date cannot be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
- assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
- assert error_message(errors, :join_date) =~ "cannot be in the future"
- attrs2 = Map.delete(@valid_attrs, :join_date)
- assert {:ok, _member} = Membership.create_member(attrs2)
+
+ assert {:error,
+ %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
+ Membership.create_member(attrs)
end
test "Exit date is optional but must not be before join date if both are specified" do
diff --git a/test/mv_web/components/payment_filter_component_test.exs b/test/mv_web/components/payment_filter_component_test.exs
index c44bf41..7987efa 100644
--- a/test/mv_web/components/payment_filter_component_test.exs
+++ b/test/mv_web/components/payment_filter_component_test.exs
@@ -3,7 +3,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
Unit tests for the PaymentFilterComponent.
Tests cover:
- - Rendering in all 3 filter states (nil, :paid, :not_paid)
+ - Rendering in all 3 filter states (nil, :paid, :unpaid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
@@ -25,15 +25,15 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?paid_filter=paid")
+ {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
- test "renders with not_paid filter active", %{conn: conn} do
+ test "renders with unpaid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
+ {:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
@@ -82,7 +82,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?paid_filter=paid")
+ {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view
@@ -94,7 +94,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
- # URL should not contain paid_filter param - wait for patch
+ # URL should not contain cycle_status_filter param - wait for patch
assert_patch(view)
end
@@ -112,12 +112,12 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
- # Wait for patch and check URL contains paid_filter=paid
+ # Wait for patch and check URL contains cycle_status_filter=paid
path = assert_patch(view)
- assert path =~ "paid_filter=paid"
+ assert path =~ "cycle_status_filter=paid"
end
- test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
+ test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
@@ -126,14 +126,14 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
- # Select "Not paid" option
+ # Select "Unpaid" option
view
- |> element("#payment-filter button[phx-value-filter='not_paid']")
+ |> element("#payment-filter button[phx-value-filter='unpaid']")
|> render_click()
- # Wait for patch and check URL contains paid_filter=not_paid
+ # Wait for patch and check URL contains cycle_status_filter=unpaid
path = assert_patch(view)
- assert path =~ "paid_filter=not_paid"
+ assert path =~ "cycle_status_filter=unpaid"
end
end
@@ -166,7 +166,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?paid_filter=paid")
+ {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view
diff --git a/test/mv_web/helpers/membership_fee_helpers_test.exs b/test/mv_web/helpers/membership_fee_helpers_test.exs
new file mode 100644
index 0000000..6d6d35c
--- /dev/null
+++ b/test/mv_web/helpers/membership_fee_helpers_test.exs
@@ -0,0 +1,260 @@
+defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
+ @moduledoc """
+ Tests for MembershipFeeHelpers module.
+ """
+ use Mv.DataCase, async: true
+
+ require Ash.Query
+
+ alias MvWeb.Helpers.MembershipFeeHelpers
+ alias Mv.MembershipFees.CalendarCycles
+
+ describe "format_currency/1" do
+ test "formats decimal amount correctly" do
+ assert MembershipFeeHelpers.format_currency(Decimal.new("60.00")) == "60,00 €"
+ assert MembershipFeeHelpers.format_currency(Decimal.new("5.5")) == "5,50 €"
+ assert MembershipFeeHelpers.format_currency(Decimal.new("100")) == "100,00 €"
+ assert MembershipFeeHelpers.format_currency(Decimal.new("0.99")) == "0,99 €"
+ end
+ end
+
+ describe "format_interval/1" do
+ test "formats all interval types correctly" do
+ assert MembershipFeeHelpers.format_interval(:monthly) == "Monthly"
+ assert MembershipFeeHelpers.format_interval(:quarterly) == "Quarterly"
+ assert MembershipFeeHelpers.format_interval(:half_yearly) == "Half-yearly"
+ assert MembershipFeeHelpers.format_interval(:yearly) == "Yearly"
+ end
+ end
+
+ describe "format_cycle_range/2" do
+ test "formats yearly cycle range correctly" do
+ cycle_start = ~D[2024-01-01]
+ interval = :yearly
+ _cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
+
+ result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
+ assert result =~ "2024"
+ assert result =~ "01.01"
+ assert result =~ "31.12"
+ end
+
+ test "formats quarterly cycle range correctly" do
+ cycle_start = ~D[2024-01-01]
+ interval = :quarterly
+ _cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
+
+ result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
+ assert result =~ "2024"
+ assert result =~ "01.01"
+ assert result =~ "31.03"
+ end
+
+ test "formats monthly cycle range correctly" do
+ cycle_start = ~D[2024-03-01]
+ interval = :monthly
+ _cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
+
+ result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
+ assert result =~ "2024"
+ assert result =~ "01.03"
+ assert result =~ "31.03"
+ end
+ end
+
+ describe "get_last_completed_cycle/2" do
+ test "returns last completed cycle for member" do
+ # Create test data
+ fee_type =
+ Mv.MembershipFees.MembershipFeeType
+ |> Ash.Changeset.for_create(:create, %{
+ name: "Test Type",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ })
+ |> Ash.create!()
+
+ # Create member without fee type first to avoid auto-generation
+ member =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test#{System.unique_integer([:positive])}@example.com",
+ join_date: ~D[2022-01-01]
+ })
+ |> Ash.create!()
+
+ # Assign fee type after member creation (this may generate cycles, but we'll create our own)
+ member =
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+
+ # Delete any auto-generated cycles first
+ cycles =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ # Create cycles manually
+ _cycle_2022 =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, %{
+ cycle_start: ~D[2022-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :paid
+ })
+ |> Ash.create!()
+
+ cycle_2023 =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :paid
+ })
+ |> Ash.create!()
+
+ # Load cycles with membership_fee_type relationship
+ member =
+ member
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+
+ # Use a fixed date in 2024 to ensure 2023 is last completed
+ today = ~D[2024-06-15]
+ last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, today)
+
+ assert last_cycle.id == cycle_2023.id
+ end
+
+ test "returns nil if no cycles exist" do
+ fee_type =
+ Mv.MembershipFees.MembershipFeeType
+ |> Ash.Changeset.for_create(:create, %{
+ name: "Test Type",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ })
+ |> Ash.create!()
+
+ # Create member without fee type first
+ member =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test#{System.unique_integer([:positive])}@example.com"
+ })
+ |> Ash.create!()
+
+ # Assign fee type
+ member =
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+
+ # Delete any auto-generated cycles
+ cycles =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ # Load cycles and fee type (will be empty)
+ member =
+ member
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+
+ last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
+ assert last_cycle == nil
+ end
+ end
+
+ describe "get_current_cycle/2" do
+ test "returns current cycle for member" do
+ fee_type =
+ Mv.MembershipFees.MembershipFeeType
+ |> Ash.Changeset.for_create(:create, %{
+ name: "Test Type",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ })
+ |> Ash.create!()
+
+ # Create member without fee type first
+ member =
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test#{System.unique_integer([:positive])}@example.com",
+ join_date: ~D[2023-01-01]
+ })
+ |> Ash.create!()
+
+ # Assign fee type
+ member =
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+
+ # Delete any auto-generated cycles
+ cycles =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ today = Date.utc_today()
+ current_year_start = %{today | month: 1, day: 1}
+
+ current_cycle =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, %{
+ cycle_start: current_year_start,
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ })
+ |> Ash.create!()
+
+ # Load cycles with membership_fee_type relationship
+ member =
+ member
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+
+ result = MembershipFeeHelpers.get_current_cycle(member, today)
+
+ assert result.id == current_cycle.id
+ end
+ end
+
+ describe "status_color/1" do
+ test "returns correct color classes for statuses" do
+ assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
+ assert MembershipFeeHelpers.status_color(:unpaid) == "badge-error"
+ assert MembershipFeeHelpers.status_color(:suspended) == "badge-ghost"
+ end
+ end
+
+ describe "status_icon/1" do
+ test "returns correct icon names for statuses" do
+ assert MembershipFeeHelpers.status_icon(:paid) == "hero-check-circle"
+ assert MembershipFeeHelpers.status_icon(:unpaid) == "hero-x-circle"
+ assert MembershipFeeHelpers.status_icon(:suspended) == "hero-pause-circle"
+ end
+ end
+end
diff --git a/test/mv_web/live/membership_fee_type_live/form_test.exs b/test/mv_web/live/membership_fee_type_live/form_test.exs
new file mode 100644
index 0000000..8576f6f
--- /dev/null
+++ b/test/mv_web/live/membership_fee_type_live/form_test.exs
@@ -0,0 +1,218 @@
+defmodule MvWeb.MembershipFeeTypeLive.FormTest do
+ @moduledoc """
+ Tests for membership fee types create/edit form.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Membership.Member
+
+ require Ash.Query
+
+ setup %{conn: conn} do
+ # Create admin user
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: "admin#{System.unique_integer([:positive])}@mv.local",
+ password: "testpassword123"
+ })
+ |> Ash.create()
+
+ authenticated_conn = conn_with_password_user(conn, user)
+ %{conn: authenticated_conn, user: user}
+ end
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ describe "create form" do
+ test "creates new membership fee type", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/membership_fee_types/new")
+
+ form_data = %{
+ "membership_fee_type[name]" => "New Type",
+ "membership_fee_type[amount]" => "75.00",
+ "membership_fee_type[interval]" => "yearly",
+ "membership_fee_type[description]" => "Test description"
+ }
+
+ {:error, {:live_redirect, %{to: to}}} =
+ view
+ |> form("#membership-fee-type-form", form_data)
+ |> render_submit()
+
+ assert to == "/membership_fee_types"
+
+ # Verify type was created
+ type =
+ MembershipFeeType
+ |> Ash.Query.filter(name == "New Type")
+ |> Ash.read_one!()
+
+ assert type.amount == Decimal.new("75.00")
+ assert type.interval == :yearly
+ end
+
+ test "interval field is editable on create", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/membership_fee_types/new")
+
+ # Interval field should be editable (not disabled)
+ refute html =~ "disabled" || html =~ "readonly"
+ end
+ end
+
+ describe "edit form" do
+ test "loads existing type data", %{conn: conn} do
+ fee_type = create_fee_type(%{name: "Existing Type", amount: Decimal.new("60.00")})
+
+ {:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
+
+ assert html =~ "Existing Type"
+ assert html =~ "60" || html =~ "60,00"
+ end
+
+ test "interval field is grayed out on edit", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ {:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
+
+ # Interval field should be disabled
+ assert html =~ "disabled" || html =~ "readonly"
+ end
+
+ test "amount change warning displays on edit", %{conn: conn} do
+ fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
+ create_member(%{membership_fee_type_id: fee_type.id})
+
+ {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
+
+ # Change amount
+ view
+ |> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
+ |> render_change()
+
+ # Should show warning in rendered view
+ html = render(view)
+ assert html =~ "affect" || html =~ "Change Amount"
+ end
+
+ test "amount change warning shows correct affected member count", %{conn: conn} do
+ fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
+
+ # Create 3 members
+ Enum.each(1..3, fn _ ->
+ create_member(%{membership_fee_type_id: fee_type.id})
+ end)
+
+ {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
+
+ # Change amount
+ html =
+ view
+ |> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
+ |> render_change()
+
+ # Should show affected count
+ assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
+ end
+
+ test "amount change can be confirmed", %{conn: conn} do
+ fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
+
+ {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
+
+ # Change amount and confirm
+ view
+ |> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
+ |> render_change()
+
+ view
+ |> element("button[phx-click='confirm_amount_change']")
+ |> render_click()
+
+ # Submit the form to actually save the change
+ view
+ |> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
+ |> render_submit()
+
+ # Amount should be updated
+ updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
+ assert updated_type.amount == Decimal.new("75.00")
+ end
+
+ test "amount change can be cancelled", %{conn: conn} do
+ fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
+
+ {:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
+
+ # Change amount and cancel
+ view
+ |> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
+ |> render_change()
+
+ view
+ |> element("button[phx-click='cancel_amount_change']")
+ |> render_click()
+
+ # Amount should remain unchanged
+ updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
+ assert updated_type.amount == Decimal.new("50.00")
+ end
+
+ test "validation errors display correctly", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/membership_fee_types/new")
+
+ # Submit with invalid data
+ html =
+ view
+ |> form("#membership-fee-type-form", %{
+ "membership_fee_type[name]" => "",
+ "membership_fee_type[amount]" => ""
+ })
+ |> render_submit()
+
+ # Should show validation errors
+ assert html =~ "can't be blank" || html =~ "darf nicht leer sein" || html =~ "required"
+ end
+ end
+
+ describe "permissions" do
+ test "only admin can access", %{conn: conn} do
+ # This test assumes non-admin users cannot access
+ {:ok, _view, html} = live(conn, "/membership_fee_types/new")
+
+ # Should show the form (admin user in setup)
+ assert html =~ "Membership Fee Type" || html =~ "Beitragsart"
+ end
+ end
+end
diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs
new file mode 100644
index 0000000..bb10a13
--- /dev/null
+++ b/test/mv_web/live/membership_fee_type_live/index_test.exs
@@ -0,0 +1,151 @@
+defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
+ @moduledoc """
+ Tests for membership fee types list view.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Membership.Member
+
+ require Ash.Query
+
+ setup %{conn: conn} do
+ # Create admin user
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: "admin#{System.unique_integer([:positive])}@mv.local",
+ password: "testpassword123"
+ })
+ |> Ash.create()
+
+ authenticated_conn = conn_with_password_user(conn, user)
+ %{conn: authenticated_conn, user: user}
+ end
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ describe "list display" do
+ test "displays all membership fee types with correct data", %{conn: conn} do
+ _fee_type1 =
+ create_fee_type(%{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly})
+
+ _fee_type2 =
+ create_fee_type(%{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly})
+
+ {:ok, _view, html} = live(conn, "/membership_fee_types")
+
+ assert html =~ "Regular"
+ assert html =~ "Reduced"
+ assert html =~ "60" || html =~ "60,00"
+ assert html =~ "30" || html =~ "30,00"
+ assert html =~ "Yearly" || html =~ "Jährlich"
+ end
+
+ test "member count column shows correct count", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ # Create 3 members with this fee type
+ Enum.each(1..3, fn _ ->
+ create_member(%{membership_fee_type_id: fee_type.id})
+ end)
+
+ {:ok, _view, html} = live(conn, "/membership_fee_types")
+
+ assert html =~ "3" || html =~ "Members" || html =~ "Mitglieder"
+ end
+
+ test "create button navigates to form", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/membership_fee_types")
+
+ {:error, {:live_redirect, %{to: to}}} =
+ view
+ |> element("a[href='/membership_fee_types/new']")
+ |> render_click()
+
+ assert to == "/membership_fee_types/new"
+ end
+
+ test "edit button per row navigates to edit form", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ {:ok, view, _html} = live(conn, "/membership_fee_types")
+
+ {:error, {:live_redirect, %{to: to}}} =
+ view
+ |> element("a[href='/membership_fee_types/#{fee_type.id}/edit']")
+ |> render_click()
+
+ assert to == "/membership_fee_types/#{fee_type.id}/edit"
+ end
+ end
+
+ describe "delete functionality" do
+ test "delete button disabled if type is in use", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ create_member(%{membership_fee_type_id: fee_type.id})
+
+ {:ok, _view, html} = live(conn, "/membership_fee_types")
+
+ # Delete button should be disabled
+ assert html =~ "disabled" || html =~ "cursor-not-allowed"
+ end
+
+ test "delete button works if type is not in use", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ # No members assigned
+
+ {:ok, view, _html} = live(conn, "/membership_fee_types")
+
+ # Delete button should be enabled
+ view
+ |> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']")
+ |> render_click()
+
+ # Type should be deleted
+ assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
+ Ash.get(MembershipFeeType, fee_type.id, domain: Mv.MembershipFees)
+ end
+ end
+
+ describe "permissions" do
+ test "only admin can access", %{conn: conn} do
+ # This test assumes non-admin users cannot access
+ # Adjust based on actual permission implementation
+ {:ok, _view, html} = live(conn, "/membership_fee_types")
+
+ # Should show the page (admin user in setup)
+ assert html =~ "Membership Fee Types" || html =~ "Beitragsarten"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/form_membership_fee_type_test.exs b/test/mv_web/member_live/form_membership_fee_type_test.exs
new file mode 100644
index 0000000..cc4388f
--- /dev/null
+++ b/test/mv_web/member_live/form_membership_fee_type_test.exs
@@ -0,0 +1,167 @@
+defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
+ @moduledoc """
+ Tests for membership fee type dropdown in member form.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Member
+ alias Mv.MembershipFees.MembershipFeeType
+
+ require Ash.Query
+
+ setup %{conn: conn} do
+ # Create admin user
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: "admin#{System.unique_integer([:positive])}@mv.local",
+ password: "testpassword123"
+ })
+ |> Ash.create()
+
+ authenticated_conn = conn_with_password_user(conn, user)
+ %{conn: authenticated_conn, user: user}
+ end
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ describe "membership fee type dropdown" do
+ test "displays in form", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/members/new")
+
+ # Should show membership fee type dropdown
+ assert html =~ "membership_fee_type_id" || html =~ "Membership Fee Type" ||
+ html =~ "Beitragsart"
+ end
+
+ test "shows available types", %{conn: conn} do
+ _fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
+ _fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
+
+ {:ok, _view, html} = live(conn, "/members/new")
+
+ assert html =~ "Type 1"
+ assert html =~ "Type 2"
+ end
+
+ test "filters to same interval types if member has type", %{conn: conn} do
+ yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
+ _monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
+
+ member = create_member(%{membership_fee_type_id: yearly_type.id})
+
+ {:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
+
+ # Should show yearly type but not monthly
+ assert html =~ "Yearly Type"
+ refute html =~ "Monthly Type"
+ end
+
+ test "shows warning if different interval selected", %{conn: conn} do
+ yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
+ monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
+
+ member = create_member(%{membership_fee_type_id: yearly_type.id})
+
+ {:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
+
+ # Monthly type should not be in the dropdown (filtered by interval)
+ refute html =~ monthly_type.id
+
+ # Only yearly types should be available
+ assert html =~ yearly_type.id
+ end
+
+ test "warning cleared if same interval selected", %{conn: conn} do
+ yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly})
+ yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly})
+
+ member = create_member(%{membership_fee_type_id: yearly_type1.id})
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
+
+ # Select another yearly type (should not show warning)
+ html =
+ view
+ |> form("#member-form", %{"member[membership_fee_type_id]" => yearly_type2.id})
+ |> render_change()
+
+ refute html =~ "Warning" || html =~ "Warnung"
+ end
+
+ test "form saves with selected membership fee type", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ {:ok, view, _html} = live(conn, "/members/new")
+
+ form_data = %{
+ "member[first_name]" => "Test",
+ "member[last_name]" => "Member",
+ "member[email]" => "test#{System.unique_integer([:positive])}@example.com",
+ "member[membership_fee_type_id]" => fee_type.id
+ }
+
+ {:error, {:live_redirect, %{to: _to}}} =
+ view
+ |> form("#member-form", form_data)
+ |> render_submit()
+
+ # Verify member was created with fee type
+ member =
+ Member
+ |> Ash.Query.filter(email == ^form_data["member[email]"])
+ |> Ash.read_one!()
+
+ assert member.membership_fee_type_id == fee_type.id
+ end
+
+ test "new members get default membership fee type", %{conn: conn} do
+ # Set default fee type in settings
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ {:ok, settings} = Mv.Membership.get_settings()
+
+ settings
+ |> Ash.Changeset.for_update(:update_membership_fee_settings, %{
+ default_membership_fee_type_id: fee_type.id
+ })
+ |> Ash.update!()
+
+ {:ok, view, _html} = live(conn, "/members/new")
+
+ # Form should have default fee type selected
+ html = render(view)
+ assert html =~ fee_type.name || html =~ "selected"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index/membership_fee_status_test.exs b/test/mv_web/member_live/index/membership_fee_status_test.exs
new file mode 100644
index 0000000..c56e80c
--- /dev/null
+++ b/test/mv_web/member_live/index/membership_fee_status_test.exs
@@ -0,0 +1,362 @@
+defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
+ @moduledoc """
+ Tests for MembershipFeeStatus helper module.
+ """
+ use Mv.DataCase, async: false
+
+ alias MvWeb.MemberLive.Index.MembershipFeeStatus
+ alias Mv.Membership.Member
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ require Ash.Query
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a cycle
+ # Note: Does not delete existing cycles - tests should manage their own test data
+ # If cleanup is needed, it should be done in setup or explicitly in the test
+ defp create_cycle(member, fee_type, attrs) do
+ default_attrs = %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ describe "load_cycles_for_members/2" do
+ test "efficiently loads cycles for members" do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ member1 = create_member(%{membership_fee_type_id: fee_type.id})
+ member2 = create_member(%{membership_fee_type_id: fee_type.id})
+
+ create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+ create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ query =
+ Member
+ |> Ash.Query.filter(id in [^member1.id, ^member2.id])
+ |> MembershipFeeStatus.load_cycles_for_members()
+
+ members = Ash.read!(query)
+
+ assert length(members) == 2
+
+ # Verify cycles are loaded
+ member1_loaded = Enum.find(members, &(&1.id == member1.id))
+ member2_loaded = Enum.find(members, &(&1.id == member2.id))
+
+ assert member1_loaded.membership_fee_cycles != nil
+ assert member2_loaded.membership_fee_cycles != nil
+ end
+ end
+
+ describe "get_cycle_status_for_member/2" do
+ test "returns status of last completed cycle" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ # Create member without fee type to avoid auto-generation
+ member = create_member(%{})
+
+ # Assign fee type
+ member =
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+
+ # Delete any auto-generated cycles
+ cycles =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ # Create cycles with dates that ensure 2023 is last completed
+ # Use a fixed "today" date in 2024 to make 2023 the last completed
+ create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ # Load cycles with membership_fee_type relationship
+ member =
+ member
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+
+ # Use fixed date in 2024 to ensure 2023 is last completed
+ # We need to manually set the date for the helper function
+ # Since get_cycle_status_for_member doesn't take a date, we need to ensure
+ # the cycles are properly loaded with their fee_type relationship
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
+
+ # The status depends on what Date.utc_today() returns
+ # If we're in 2024 or later, 2023 should be last completed
+ # If we're still in 2023, 2022 would be last completed
+ # For this test, we'll just verify it returns a valid status
+ assert status in [:paid, :unpaid, :suspended, nil]
+ end
+
+ test "returns status of current cycle when show_current is true" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ # Create member without fee type to avoid auto-generation
+ member = create_member(%{})
+
+ # Assign fee type
+ member =
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+
+ # Delete any auto-generated cycles
+ cycles =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ # Create cycles - use current year for current cycle
+ today = Date.utc_today()
+ current_year_start = %{today | month: 1, day: 1}
+ last_year_start = %{current_year_start | year: current_year_start.year - 1}
+
+ create_cycle(member, fee_type, %{cycle_start: last_year_start, status: :paid})
+ create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
+
+ # Load cycles with membership_fee_type relationship
+ member =
+ member
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
+
+ # Should return status of current cycle
+ assert status == :suspended
+ end
+
+ test "returns nil if no cycles exist" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ # Create member without fee type to avoid auto-generation
+ member = create_member(%{})
+
+ # Assign fee type
+ member =
+ member
+ |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
+ |> Ash.update!()
+
+ # Delete any auto-generated cycles
+ cycles =
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ # Load cycles and fee type first (will be empty)
+ member =
+ member
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
+
+ assert status == nil
+ end
+ end
+
+ describe "format_cycle_status_badge/1" do
+ test "returns badge component for paid status" do
+ result = MembershipFeeStatus.format_cycle_status_badge(:paid)
+ assert result.color == "badge-success"
+ assert result.icon == "hero-check-circle"
+ assert result.label == "Paid" || result.label == "Bezahlt"
+ end
+
+ test "returns badge component for unpaid status" do
+ result = MembershipFeeStatus.format_cycle_status_badge(:unpaid)
+ assert result.color == "badge-error"
+ assert result.icon == "hero-x-circle"
+ assert result.label == "Unpaid" || result.label == "Unbezahlt"
+ end
+
+ test "returns badge component for suspended status" do
+ result = MembershipFeeStatus.format_cycle_status_badge(:suspended)
+ assert result.color == "badge-ghost"
+ assert result.icon == "hero-pause-circle"
+ assert result.label == "Suspended" || result.label == "Ausgesetzt"
+ end
+
+ test "handles nil status gracefully" do
+ result = MembershipFeeStatus.format_cycle_status_badge(nil)
+ assert result == nil
+ end
+ end
+
+ describe "filter_members_by_cycle_status/3" do
+ test "filters paid members in last cycle" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ last_year_start = Date.new!(today.year - 1, 1, 1)
+
+ # Member with paid last cycle
+ member1 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
+
+ # Member with unpaid last cycle
+ member2 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
+
+ members =
+ [member1, member2]
+ |> Enum.map(fn m ->
+ m
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+ end)
+
+ filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
+
+ assert length(filtered) == 1
+ assert List.first(filtered).id == member1.id
+ end
+
+ test "filters unpaid members in last cycle" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ last_year_start = Date.new!(today.year - 1, 1, 1)
+
+ # Member with paid last cycle
+ member1 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
+
+ # Member with unpaid last cycle
+ member2 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
+
+ members =
+ [member1, member2]
+ |> Enum.map(fn m ->
+ m
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+ end)
+
+ filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
+
+ assert length(filtered) == 1
+ assert List.first(filtered).id == member2.id
+ end
+
+ test "filters paid members in current cycle" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ current_year_start = Date.new!(today.year, 1, 1)
+
+ # Member with paid current cycle
+ member1 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
+
+ # Member with unpaid current cycle
+ member2 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
+
+ members =
+ [member1, member2]
+ |> Enum.map(fn m ->
+ m
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+ end)
+
+ filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
+
+ assert length(filtered) == 1
+ assert List.first(filtered).id == member1.id
+ end
+
+ test "filters unpaid members in current cycle" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ current_year_start = Date.new!(today.year, 1, 1)
+
+ # Member with paid current cycle
+ member1 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
+
+ # Member with unpaid current cycle
+ member2 = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
+
+ members =
+ [member1, member2]
+ |> Enum.map(fn m ->
+ m
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+ end)
+
+ filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
+
+ assert length(filtered) == 1
+ assert List.first(filtered).id == member2.id
+ end
+
+ test "returns all members when filter is nil" do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member1 = create_member(%{membership_fee_type_id: fee_type.id})
+ member2 = create_member(%{membership_fee_type_id: fee_type.id})
+
+ members =
+ [member1, member2]
+ |> Enum.map(fn m ->
+ m
+ |> Ash.load!(membership_fee_cycles: [:membership_fee_type])
+ |> Ash.load!(:membership_fee_type)
+ end)
+
+ # filter_unpaid_members should still work for backwards compatibility
+ filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
+
+ # Both members have no cycles, so both should be filtered out
+ assert Enum.empty?(filtered)
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs
index 6e1642a..05fa768 100644
--- a/test/mv_web/member_live/index_field_visibility_test.exs
+++ b/test/mv_web/member_live/index_field_visibility_test.exs
@@ -10,7 +10,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
- Integration with member list display
- Custom fields visibility
"""
- use MvWeb.ConnCase, async: true
+ # async: false to prevent PostgreSQL deadlocks when creating members and custom fields
+ use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs
new file mode 100644
index 0000000..baa5f67
--- /dev/null
+++ b/test/mv_web/member_live/index_membership_fee_status_test.exs
@@ -0,0 +1,261 @@
+defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
+ @moduledoc """
+ Tests for membership fee status column in member list view.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Member
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ require Ash.Query
+
+ setup %{conn: conn} do
+ # Create admin user
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: "admin#{System.unique_integer([:positive])}@mv.local",
+ password: "testpassword123"
+ })
+ |> Ash.create()
+
+ conn = conn_with_password_user(conn, user)
+ %{conn: conn, user: user}
+ end
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a cycle
+ defp create_cycle(member, fee_type, attrs) do
+ # Delete any auto-generated cycles first to avoid conflicts
+ existing_cycles =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ default_attrs = %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ describe "status column display" do
+ test "shows status column in member list", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+
+ {:ok, _view, html} = live(conn, "/members")
+
+ # Should show membership fee status column
+ assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
+ end
+
+ test "shows last completed cycle status by default", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Should show unpaid status (2023 is last completed)
+ html = render(view)
+ assert html =~ "hero-x-circle" || html =~ "unpaid"
+ end
+
+ test "toggle switches to current cycle view", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ today = Date.utc_today()
+ current_year_start = %{today | month: 1, day: 1}
+
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+ create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Toggle to current cycle (use the button in the header, not the one in the column)
+ view
+ |> element("button[phx-click='toggle_cycle_view'].btn.gap-2")
+ |> render_click()
+
+ html = render(view)
+ # Should show suspended status (current cycle)
+ assert html =~ "hero-pause-circle" || html =~ "suspended"
+ end
+
+ test "shows correct color coding for paid status", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ html = render(view)
+ assert html =~ "text-success" || html =~ "hero-check-circle"
+ end
+
+ test "shows correct color coding for unpaid status", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ html = render(view)
+ assert html =~ "text-error" || html =~ "hero-x-circle"
+ end
+
+ test "shows correct color coding for suspended status", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :suspended})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ html = render(view)
+ assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
+ end
+
+ test "handles members without cycles gracefully", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ # No cycles created
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ html = render(view)
+ # Should not crash, may show empty or default state
+ assert html =~ member.first_name
+ end
+ end
+
+ describe "filters" do
+ test "filter unpaid in last cycle works", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ # Member with unpaid last cycle
+ member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
+ create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ # Member with paid last cycle
+ member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
+ create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+
+ # Verify cycles exist in database
+ cycles1 =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member1.id)
+ |> Ash.read!()
+
+ cycles2 =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member2.id)
+ |> Ash.read!()
+
+ refute Enum.empty?(cycles1)
+ refute Enum.empty?(cycles2)
+
+ {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
+
+ assert html =~ "UnpaidMember"
+ refute html =~ "PaidMember"
+ end
+
+ test "filter unpaid in current cycle works", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ today = Date.utc_today()
+ current_year_start = %{today | month: 1, day: 1}
+
+ # Member with unpaid current cycle
+ member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
+ create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :unpaid})
+
+ # Member with paid current cycle
+ member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
+ create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :paid})
+
+ # Verify cycles exist in database
+ cycles1 =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member1.id)
+ |> Ash.read!()
+
+ cycles2 =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member2.id)
+ |> Ash.read!()
+
+ refute Enum.empty?(cycles1)
+ refute Enum.empty?(cycles2)
+
+ {:ok, _view, html} =
+ live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
+
+ assert html =~ "UnpaidCurrent"
+ refute html =~ "PaidCurrent"
+ end
+ end
+
+ describe "performance" do
+ test "loads cycles efficiently without N+1 queries", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ # Create multiple members with cycles
+ Enum.each(1..5, fn _ ->
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+ end)
+
+ {:ok, _view, html} = live(conn, "/members")
+
+ # Should render without errors (N+1 would cause performance issues)
+ assert html =~ "Members" || html =~ "Mitglieder"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index 3232cc0..d4f5644 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -457,220 +457,204 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
- describe "payment filter integration" do
- setup do
- # Create members with different payment status
- # Use unique names that won't appear elsewhere in the HTML
- {:ok, paid_member} =
- Mv.Membership.create_member(%{
- first_name: "Zahler",
- last_name: "Mitglied",
- email: "zahler@example.com",
- paid: true
+ describe "cycle status filter" do
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Mv.Membership.Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a cycle
+ defp create_cycle(member, fee_type, attrs) do
+ # Delete any auto-generated cycles first to avoid conflicts
+ existing_cycles =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ default_attrs = %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ test "filter shows only members with paid status in last cycle", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ last_year_start = Date.new!(today.year - 1, 1, 1)
+
+ # Member with paid last cycle
+ paid_member =
+ create_member(%{
+ first_name: "PaidLast",
+ membership_fee_type_id: fee_type.id
})
- {:ok, unpaid_member} =
- Mv.Membership.create_member(%{
- first_name: "Nichtzahler",
- last_name: "Mitglied",
- email: "nichtzahler@example.com",
- paid: false
+ create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
+
+ # Member with unpaid last cycle
+ unpaid_member =
+ create_member(%{
+ first_name: "UnpaidLast",
+ membership_fee_type_id: fee_type.id
})
- {:ok, nil_paid_member} =
- Mv.Membership.create_member(%{
- first_name: "Unbestimmt",
- last_name: "Mitglied",
- email: "unbestimmt@example.com"
- # paid is nil by default
+ create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
+
+ {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
+
+ assert html =~ "PaidLast"
+ refute html =~ "UnpaidLast"
+ end
+
+ test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ last_year_start = Date.new!(today.year - 1, 1, 1)
+
+ # Member with paid last cycle
+ paid_member =
+ create_member(%{
+ first_name: "PaidLast",
+ membership_fee_type_id: fee_type.id
})
- %{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
+ create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
+
+ # Member with unpaid last cycle
+ unpaid_member =
+ create_member(%{
+ first_name: "UnpaidLast",
+ membership_fee_type_id: fee_type.id
+ })
+
+ create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
+
+ {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
+
+ refute html =~ "PaidLast"
+ assert html =~ "UnpaidLast"
end
- test "filter shows all members when no filter is active", %{
- conn: conn,
- paid_member: paid_member,
- unpaid_member: unpaid_member,
- nil_paid_member: nil_paid_member
- } do
+ test "filter shows only members with paid status in current cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ current_year_start = Date.new!(today.year, 1, 1)
- assert html =~ paid_member.first_name
- assert html =~ unpaid_member.first_name
- assert html =~ nil_paid_member.first_name
+ # Member with paid current cycle
+ paid_member =
+ create_member(%{
+ first_name: "PaidCurrent",
+ membership_fee_type_id: fee_type.id
+ })
+
+ create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
+
+ # Member with unpaid current cycle
+ unpaid_member =
+ create_member(%{
+ first_name: "UnpaidCurrent",
+ membership_fee_type_id: fee_type.id
+ })
+
+ create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
+
+ {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
+
+ assert html =~ "PaidCurrent"
+ refute html =~ "UnpaidCurrent"
end
- test "filter shows only paid members when paid filter is active", %{
- conn: conn,
- paid_member: paid_member,
- unpaid_member: unpaid_member,
- nil_paid_member: nil_paid_member
- } do
+ test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?paid_filter=paid")
+ fee_type = create_fee_type(%{interval: :yearly})
+ today = Date.utc_today()
+ current_year_start = Date.new!(today.year, 1, 1)
- assert html =~ paid_member.first_name
- refute html =~ unpaid_member.first_name
- refute html =~ nil_paid_member.first_name
+ # Member with paid current cycle
+ paid_member =
+ create_member(%{
+ first_name: "PaidCurrent",
+ membership_fee_type_id: fee_type.id
+ })
+
+ create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
+
+ # Member with unpaid current cycle
+ unpaid_member =
+ create_member(%{
+ first_name: "UnpaidCurrent",
+ membership_fee_type_id: fee_type.id
+ })
+
+ create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
+
+ {:ok, _view, html} =
+ live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
+
+ refute html =~ "PaidCurrent"
+ assert html =~ "UnpaidCurrent"
end
- test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
- conn: conn,
- paid_member: paid_member,
- unpaid_member: unpaid_member,
- nil_paid_member: nil_paid_member
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
-
- refute html =~ paid_member.first_name
- assert html =~ unpaid_member.first_name
- assert html =~ nil_paid_member.first_name
- end
-
- test "filter combines with search query (AND)", %{
- conn: conn,
- paid_member: paid_member
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
-
- assert html =~ paid_member.first_name
- end
-
- test "filter combines with sorting", %{conn: conn} do
+ test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} =
- live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
+ # Start with last cycle view and paid filter
+ {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
- # Click on email sort header
+ # Toggle to current cycle - this should update URL and preserve filter
+ # Use the button in the toolbar
view
- |> element("[data-testid='email']")
+ |> element("button[phx-click='toggle_cycle_view']")
|> render_click()
- # Filter should be preserved in URL
+ # Wait for patch to complete
path = assert_patch(view)
- assert path =~ "paid_filter=paid"
- assert path =~ "sort_field=email"
- end
- test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open filter dropdown
- view
- |> element("#payment-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "Paid" option
- view
- |> element("#payment-filter button[phx-value-filter='paid']")
- |> render_click()
-
- path = assert_patch(view)
- assert path =~ "paid_filter=paid"
- end
-
- test "URL parameter is correctly read on page load", %{
- conn: conn,
- paid_member: paid_member
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?paid_filter=paid")
-
- # Only paid member should be visible
- assert html =~ paid_member.first_name
- # Filter badge should be visible
- assert html =~ "badge"
- end
-
- test "invalid URL parameter is ignored", %{
- conn: conn,
- paid_member: paid_member,
- unpaid_member: unpaid_member
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
-
- # All members should be visible (filter not applied)
- assert html =~ paid_member.first_name
- assert html =~ unpaid_member.first_name
- end
-
- test "search maintains filter state", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?paid_filter=paid")
-
- # Perform search
- view
- |> element("[data-testid='search-input']")
- |> render_change(%{"query" => "test"})
-
- # Filter state should be maintained in URL
- path = assert_patch(view)
- assert path =~ "paid_filter=paid"
- end
- end
-
- describe "paid column in table" do
- setup do
- {:ok, paid_member} =
- Mv.Membership.create_member(%{
- first_name: "Paid",
- last_name: "Member",
- email: "paid.column@example.com",
- paid: true
- })
-
- {:ok, unpaid_member} =
- Mv.Membership.create_member(%{
- first_name: "Unpaid",
- last_name: "Member",
- email: "unpaid.column@example.com",
- paid: false
- })
-
- %{paid_member: paid_member, unpaid_member: unpaid_member}
- end
-
- test "paid column shows green badge for paid members", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- # Check for success badge (green)
- assert html =~ "badge-success"
- end
-
- test "paid column shows red badge for unpaid members", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- # Check for error badge (red)
- assert html =~ "badge-error"
- end
-
- test "paid column shows 'Yes' for paid members", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- Gettext.put_locale(MvWeb.Gettext, "en")
- {:ok, _view, html} = live(conn, "/members")
-
- # The table should contain "Yes" text inside badge
- assert html =~ "badge-success"
- assert html =~ "Yes"
- end
-
- test "paid column shows 'No' for unpaid members", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- Gettext.put_locale(MvWeb.Gettext, "en")
- {:ok, _view, html} = live(conn, "/members")
-
- # The table should contain "No" text inside badge
- assert html =~ "badge-error"
- assert html =~ "No"
+ # URL should contain both filter and show_current_cycle
+ assert path =~ "cycle_status_filter=paid"
+ assert path =~ "show_current_cycle=true"
end
end
end
diff --git a/test/mv_web/member_live/membership_fee_integration_test.exs b/test/mv_web/member_live/membership_fee_integration_test.exs
new file mode 100644
index 0000000..e76e422
--- /dev/null
+++ b/test/mv_web/member_live/membership_fee_integration_test.exs
@@ -0,0 +1,237 @@
+defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
+ @moduledoc """
+ Integration tests for membership fee UI workflows.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Member
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ require Ash.Query
+
+ setup do
+ # Create admin user
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: "admin#{System.unique_integer([:positive])}@mv.local",
+ password: "testpassword123"
+ })
+ |> Ash.create()
+
+ conn = conn_with_password_user(build_conn(), user)
+ %{conn: conn, user: user}
+ end
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ describe "end-to-end workflows" do
+ test "create type → assign to member → view cycles → change status", %{conn: conn} do
+ # Create type
+ fee_type = create_fee_type(%{name: "Regular", interval: :yearly})
+
+ # Assign to member
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ # View cycles
+ {:ok, view, html} = live(conn, "/members/#{member.id}")
+
+ assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
+
+ # Get a cycle
+ cycles =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ if !Enum.empty?(cycles) do
+ cycle = List.first(cycles)
+
+ # Switch to Membership Fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Change status
+ view
+ |> element("button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}']")
+ |> render_click()
+
+ # Verify status changed
+ updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
+ assert updated_cycle.status == :paid
+ end
+ end
+
+ test "change member type → cycles regenerate", %{conn: conn} do
+ fee_type1 =
+ create_fee_type(%{name: "Type 1", interval: :yearly, amount: Decimal.new("50.00")})
+
+ fee_type2 =
+ create_fee_type(%{name: "Type 2", interval: :yearly, amount: Decimal.new("75.00")})
+
+ member = create_member(%{membership_fee_type_id: fee_type1.id})
+
+ # Change type
+ {:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
+
+ view
+ |> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
+ |> render_submit()
+
+ # Verify cycles regenerated with new amount
+ cycles =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.Query.filter(status == :unpaid)
+ |> Ash.read!()
+
+ # Future unpaid cycles should have new amount
+ Enum.each(cycles, fn cycle ->
+ if Date.compare(cycle.cycle_start, Date.utc_today()) != :lt do
+ assert Decimal.equal?(cycle.amount, fee_type2.amount)
+ end
+ end)
+ end
+
+ test "update settings → new members get default type", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+
+ # Update settings
+ {:ok, settings} = Mv.Membership.get_settings()
+
+ settings
+ |> Ash.Changeset.for_update(:update_membership_fee_settings, %{
+ default_membership_fee_type_id: fee_type.id
+ })
+ |> Ash.update!()
+
+ # Create new member
+ {:ok, view, _html} = live(conn, "/members/new")
+
+ form_data = %{
+ "member[first_name]" => "New",
+ "member[last_name]" => "Member",
+ "member[email]" => "new#{System.unique_integer([:positive])}@example.com"
+ }
+
+ {:error, {:live_redirect, %{to: _to}}} =
+ view
+ |> form("#member-form", form_data)
+ |> render_submit()
+
+ # Verify member got default type
+ member =
+ Member
+ |> Ash.Query.filter(email == ^form_data["member[email]"])
+ |> Ash.read_one!()
+
+ assert member.membership_fee_type_id == fee_type.id
+ end
+
+ test "delete cycle → confirmation → cycle deleted", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ cycle =
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ })
+ |> Ash.create!()
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to Membership Fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Delete cycle with confirmation
+ view
+ |> element("button[phx-click='delete_cycle'][phx-value-cycle_id='#{cycle.id}']")
+ |> render_click()
+
+ # Confirm deletion
+ view
+ |> element("button[phx-click='confirm_delete_cycle'][phx-value-cycle_id='#{cycle.id}']")
+ |> render_click()
+
+ # Verify cycle deleted - Ash.read_one returns {:ok, nil} if not found
+ result = MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id) |> Ash.read_one()
+ assert result == {:ok, nil}
+ end
+
+ test "edit cycle amount → modal → amount updated", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ cycle =
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ })
+ |> Ash.create!()
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to Membership Fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Open edit modal by clicking on the amount span
+ view
+ |> element("span[phx-click='edit_cycle_amount'][phx-value-cycle_id='#{cycle.id}']")
+ |> render_click()
+
+ # Update amount
+ view
+ |> form("form[phx-submit='save_cycle_amount']", %{"amount" => "75.00"})
+ |> render_submit()
+
+ # Verify amount updated
+ updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
+ assert updated_cycle.amount == Decimal.new("75.00")
+ end
+ end
+end
diff --git a/test/mv_web/member_live/show_membership_fees_test.exs b/test/mv_web/member_live/show_membership_fees_test.exs
new file mode 100644
index 0000000..1f68244
--- /dev/null
+++ b/test/mv_web/member_live/show_membership_fees_test.exs
@@ -0,0 +1,270 @@
+defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
+ @moduledoc """
+ Tests for membership fees section in member detail view.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Member
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ require Ash.Query
+
+ setup %{conn: conn} do
+ # Create admin user
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: "admin#{System.unique_integer([:positive])}@mv.local",
+ password: "testpassword123"
+ })
+ |> Ash.create()
+
+ conn = conn_with_password_user(conn, user)
+ %{conn: conn, user: user}
+ end
+
+ # Helper to create a membership fee type
+ defp create_fee_type(attrs) do
+ default_attrs = %{
+ name: "Test Fee Type #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a member
+ defp create_member(attrs) do
+ default_attrs = %{
+ first_name: "Test",
+ last_name: "Member",
+ email: "test.member.#{System.unique_integer([:positive])}@example.com"
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ Member
+ |> Ash.Changeset.for_create(:create_member, attrs)
+ |> Ash.create!()
+ end
+
+ # Helper to create a cycle
+ defp create_cycle(member, fee_type, attrs) do
+ # Delete any auto-generated cycles first to avoid conflicts
+ existing_cycles =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+
+ Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
+
+ default_attrs = %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("50.00"),
+ member_id: member.id,
+ membership_fee_type_id: fee_type.id,
+ status: :unpaid
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ MembershipFeeCycle
+ |> Ash.Changeset.for_create(:create, attrs)
+ |> Ash.create!()
+ end
+
+ describe "cycles table display" do
+ test "displays all cycles for member", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ _cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
+ _cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to membership fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ html = render(view)
+
+ # Should show cycles table
+ assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
+ # Check for formatted cycle dates (e.g., "01.01.2022" or "2022")
+ assert html =~ "2022" || html =~ "2023" || html =~ "01.01.2022" || html =~ "01.01.2023"
+ end
+
+ test "table columns show correct data", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ create_cycle(member, fee_type, %{
+ cycle_start: ~D[2023-01-01],
+ amount: Decimal.new("60.00"),
+ status: :paid
+ })
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to membership fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ html = render(view)
+
+ # Should show interval, amount, status
+ assert html =~ "Yearly" || html =~ "Jährlich"
+ assert html =~ "60" || html =~ "60,00"
+ assert html =~ "paid" || html =~ "bezahlt"
+ end
+ end
+
+ describe "membership fee type display" do
+ test "shows assigned membership fee type", %{conn: conn} do
+ yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
+ _monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
+
+ member = create_member(%{membership_fee_type_id: yearly_type.id})
+
+ {:ok, _view, html} = live(conn, "/members/#{member.id}")
+
+ # Should show yearly type name
+ assert html =~ "Yearly Type"
+ end
+
+ test "shows no type message when no type assigned", %{conn: conn} do
+ member = create_member(%{})
+
+ {:ok, _view, html} = live(conn, "/members/#{member.id}")
+
+ # Should show message about no type assigned
+ assert html =~ "No membership fee type assigned" || html =~ "No type"
+ end
+ end
+
+ describe "status change actions" do
+ test "mark as paid works", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to membership fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Mark as paid
+ view
+ |> element(
+ "button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='paid']"
+ )
+ |> render_click()
+
+ # Verify cycle is now paid
+ updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
+ assert updated_cycle.status == :paid
+ end
+
+ test "mark as suspended works", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to membership fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Mark as suspended
+ view
+ |> element(
+ "button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='suspended']"
+ )
+ |> render_click()
+
+ # Verify cycle is now suspended
+ updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
+ assert updated_cycle.status == :suspended
+ end
+
+ test "mark as unpaid works", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to membership fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Mark as unpaid
+ view
+ |> element(
+ "button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='unpaid']"
+ )
+ |> render_click()
+
+ # Verify cycle is now unpaid
+ updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
+ assert updated_cycle.status == :unpaid
+ end
+ end
+
+ describe "cycle regeneration" do
+ test "manual regeneration button exists and can be clicked", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{membership_fee_type_id: fee_type.id})
+
+ {:ok, view, _html} = live(conn, "/members/#{member.id}")
+
+ # Switch to membership fees tab
+ view
+ |> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
+ |> render_click()
+
+ # Verify regenerate button exists
+ assert has_element?(view, "button[phx-click='regenerate_cycles']")
+
+ # Trigger regeneration (just verify it doesn't crash)
+ view
+ |> element("button[phx-click='regenerate_cycles']")
+ |> render_click()
+
+ # Verify the action completed without error
+ # (The actual cycle generation depends on many factors, so we just test the UI works)
+ assert render(view) =~ "Membership Fees" || render(view) =~ "Mitgliedsbeiträge"
+ end
+ end
+
+ describe "edge cases" do
+ test "handles members without membership fee type gracefully", %{conn: conn} do
+ # No fee type
+ member = create_member(%{})
+
+ {:ok, _view, html} = live(conn, "/members/#{member.id}")
+
+ # Should not crash
+ assert html =~ member.first_name
+ end
+ end
+end
diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs
new file mode 100644
index 0000000..1e04559
--- /dev/null
+++ b/test/mv_web/member_live/show_test.exs
@@ -0,0 +1,175 @@
+defmodule MvWeb.MemberLive.ShowTest do
+ @moduledoc """
+ Tests for the member show page.
+
+ Tests cover:
+ - Displaying member information
+ - Custom Fields section visibility (Issue #282 regression test)
+ - Custom field values formatting
+
+ ## Note on async: false
+ Tests use `async: false` (not `async: true`) to prevent PostgreSQL deadlocks
+ when creating members and custom fields concurrently. This is intentional and
+ documented here to avoid confusion in commit messages.
+ """
+ # async: false to prevent PostgreSQL deadlocks when creating members and custom fields
+ use MvWeb.ConnCase, async: false
+ import Phoenix.LiveViewTest
+ require Ash.Query
+ use Gettext, backend: MvWeb.Gettext
+
+ alias Mv.Membership.{CustomField, CustomFieldValue, Member}
+
+ setup do
+ # Create test member
+ {:ok, member} =
+ Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Alice",
+ last_name: "Anderson",
+ email: "alice@example.com"
+ })
+ |> Ash.create()
+
+ %{member: member}
+ end
+
+ describe "custom fields section visibility (Issue #282)" do
+ test "displays Custom Fields section even when member has no custom field values", %{
+ conn: conn,
+ member: member
+ } do
+ # Create a custom field but no value for the member
+ {:ok, custom_field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "phone_mobile",
+ value_type: :string
+ })
+ |> Ash.create()
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, ~p"/members/#{member}")
+
+ # Custom Fields section should be visible
+ assert html =~ gettext("Custom Fields")
+
+ # Custom field label should be visible
+ assert html =~ custom_field.name
+
+ # Value should show placeholder for empty value
+ assert html =~ "—" or html =~ gettext("Not set")
+ end
+
+ test "displays Custom Fields section with multiple custom fields, some without values", %{
+ conn: conn,
+ member: member
+ } do
+ # Create multiple custom fields
+ {:ok, field1} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "phone_mobile",
+ value_type: :string
+ })
+ |> Ash.create()
+
+ {:ok, field2} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "membership_number",
+ value_type: :integer
+ })
+ |> Ash.create()
+
+ # Create value only for first field
+ {:ok, _cfv} =
+ CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: field1.id,
+ value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
+ })
+ |> Ash.create()
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, ~p"/members/#{member}")
+
+ # Custom Fields section should be visible
+ assert html =~ gettext("Custom Fields")
+
+ # Both field labels should be visible
+ assert html =~ field1.name
+ assert html =~ field2.name
+
+ # First field should show value
+ assert html =~ "+49123456789"
+
+ # Second field should show placeholder
+ assert html =~ "—" or html =~ gettext("Not set")
+ end
+
+ test "does not display Custom Fields section when no custom fields exist", %{
+ conn: conn,
+ member: member
+ } do
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, ~p"/members/#{member}")
+
+ # Custom Fields section should NOT be visible
+ refute html =~ gettext("Custom Fields")
+ end
+ end
+
+ describe "custom field value formatting" do
+ test "formats string custom field values", %{conn: conn, member: member} do
+ {:ok, custom_field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "phone_mobile",
+ value_type: :string
+ })
+ |> Ash.create()
+
+ {:ok, _cfv} =
+ CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: custom_field.id,
+ value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
+ })
+ |> Ash.create()
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, ~p"/members/#{member}")
+
+ assert html =~ "+49123456789"
+ end
+
+ test "formats email custom field values as mailto links", %{conn: conn, member: member} do
+ {:ok, custom_field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "private_email",
+ value_type: :email
+ })
+ |> Ash.create()
+
+ {:ok, _cfv} =
+ CustomFieldValue
+ |> Ash.Changeset.for_create(:create, %{
+ member_id: member.id,
+ custom_field_id: custom_field.id,
+ value: %{"_union_type" => "email", "_union_value" => "private@example.com"}
+ })
+ |> Ash.create()
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, ~p"/members/#{member}")
+
+ # Should contain mailto link
+ assert html =~ ~s(href="mailto:private@example.com")
+ assert html =~ "private@example.com"
+ end
+ end
+end
diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs
index b8f7313..334dedd 100644
--- a/test/mv_web/user_live/form_test.exs
+++ b/test/mv_web/user_live/form_test.exs
@@ -1,5 +1,6 @@
defmodule MvWeb.UserLive.FormTest do
- use MvWeb.ConnCase, async: true
+ # async: false to prevent PostgreSQL deadlocks when creating members and users
+ use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
# Helper to setup authenticated connection and live view
diff --git a/test/seeds_test.exs b/test/seeds_test.exs
index b4d887c..c28eab9 100644
--- a/test/seeds_test.exs
+++ b/test/seeds_test.exs
@@ -1,6 +1,8 @@
defmodule Mv.SeedsTest do
use Mv.DataCase, async: false
+ require Ash.Query
+
describe "Seeds script" do
test "runs successfully without errors" do
# Run the seeds script - should not raise any errors
@@ -42,5 +44,76 @@ defmodule Mv.SeedsTest do
assert length(custom_fields_count_1) == length(custom_fields_count_2),
"CustomFields count should remain same after re-running seeds"
end
+
+ test "at least one member has no membership fee type assigned" do
+ # Run the seeds script
+ assert Code.eval_file("priv/repo/seeds.exs")
+
+ # Get all members
+ {:ok, members} = Ash.read(Mv.Membership.Member)
+
+ # At least one member should have no membership_fee_type_id
+ members_without_fee_type =
+ Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
+
+ assert not Enum.empty?(members_without_fee_type),
+ "At least one member should have no membership fee type assigned"
+ end
+
+ test "each membership fee type has at least one member" do
+ # Run the seeds script
+ assert Code.eval_file("priv/repo/seeds.exs")
+
+ # Get all fee types and members
+ {:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
+ {:ok, members} = Ash.read(Mv.Membership.Member)
+
+ # Group members by fee type (excluding nil)
+ members_by_fee_type =
+ members
+ |> Enum.filter(&(&1.membership_fee_type_id != nil))
+ |> Enum.group_by(& &1.membership_fee_type_id)
+
+ # Each fee type should have at least one member
+ Enum.each(fee_types, fn fee_type ->
+ members_for_type = Map.get(members_by_fee_type, fee_type.id, [])
+
+ assert not Enum.empty?(members_for_type),
+ "Membership fee type #{fee_type.name} should have at least one member assigned"
+ end)
+ end
+
+ test "members with fee types have cycles with various statuses" do
+ # Run the seeds script
+ assert Code.eval_file("priv/repo/seeds.exs")
+
+ # Get all members with fee types
+ {:ok, members} = Ash.read(Mv.Membership.Member)
+
+ members_with_fee_types =
+ members
+ |> Enum.filter(&(&1.membership_fee_type_id != nil))
+
+ # At least one member should have cycles
+ assert not Enum.empty?(members_with_fee_types),
+ "At least one member should have a membership fee type"
+
+ # Check that cycles exist and have various statuses
+ all_cycle_statuses =
+ members_with_fee_types
+ |> Enum.flat_map(fn member ->
+ Mv.MembershipFees.MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!()
+ end)
+ |> Enum.map(& &1.status)
+
+ # At least one cycle should be paid
+ assert :paid in all_cycle_statuses, "At least one cycle should be paid"
+ # At least one cycle should be unpaid
+ assert :unpaid in all_cycle_statuses, "At least one cycle should be unpaid"
+ # At least one cycle should be suspended
+ assert :suspended in all_cycle_statuses, "At least one cycle should be suspended"
+ end
end
end