Compare commits

..

8 commits

Author SHA1 Message Date
e9b99e6749 Merge pull request 'Fix hidden empty custom fields closes #282' (#313) from bugfix/228_hidden_empty_custom_field_ into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #313
2025-12-23 18:24:18 +01:00
f87e6d3e1d fix tests
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-23 18:21:15 +01:00
3cf8244cd6 fix linting errors
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-23 18:14:59 +01:00
33652265b8 feat: add accessible empty value also to member fields
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-23 17:10:52 +01:00
398a63a98f add tests for empty custom field section 2025-12-23 17:07:52 +01:00
8e58829e95 fix: improve performance loading custom fields 2025-12-23 17:07:38 +01:00
5718a37aca fix: show custom field input fields also when empty
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-23 16:15:22 +01:00
def399122c fix tests with async true 2025-12-23 16:14:58 +01:00
45 changed files with 1078 additions and 7127 deletions

1
.gitignore vendored
View file

@ -44,4 +44,3 @@ npm-debug.log
# Docker secrets directory (generated by `just init-secrets`)
/secrets/
notes.md

View file

@ -1,137 +0,0 @@
# Test Status: Membership Fee UI Components
**Date:** 2025-01-XX
**Status:** Tests Written - Implementation Complete
## Übersicht
Alle Tests für die Membership Fee UI-Komponenten wurden geschrieben. Die Tests sind TDD-konform geschrieben und sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
## Test-Dateien
### Helper Module Tests
**Datei:** `test/mv_web/helpers/membership_fee_helpers_test.exs`
- ✅ format_currency/1 formats correctly
- ✅ format_interval/1 formats all interval types
- ✅ format_cycle_range/2 formats date ranges correctly
- ✅ get_last_completed_cycle/2 returns correct cycle
- ✅ get_current_cycle/2 returns correct cycle
- ✅ status_color/1 returns correct color classes
- ✅ status_icon/1 returns correct icon names
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs`
- ✅ load_cycles_for_members/2 efficiently loads cycles
- ✅ get_cycle_status_for_member/2 returns correct status
- ✅ format_cycle_status_badge/1 returns correct badge
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member List View Tests
**Datei:** `test/mv_web/member_live/index_membership_fee_status_test.exs`
- ✅ Status column displays correctly
- ✅ Shows last completed cycle status by default
- ✅ Toggle switches to current cycle view
- ✅ Color coding for paid/unpaid/suspended
- ✅ Filter "Unpaid in last cycle" works
- ✅ Filter "Unpaid in current cycle" works
- ✅ Handles members without cycles gracefully
- ✅ Loads cycles efficiently without N+1 queries
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member Detail View Tests
**Datei:** `test/mv_web/member_live/show_membership_fees_test.exs`
- ✅ Cycles table displays all cycles
- ✅ Table columns show correct data
- ✅ Membership fee type dropdown shows only same-interval types
- ✅ Warning displayed if different interval selected
- ✅ Status change actions work (mark as paid/suspended/unpaid)
- ✅ Cycle regeneration works
- ✅ Handles members without membership fee type gracefully
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Membership Fee Types Admin Tests
**Datei:** `test/mv_web/live/membership_fee_type_live/index_test.exs`
- ✅ List displays all types with correct data
- ✅ Member count column shows correct count
- ✅ Create button navigates to form
- ✅ Edit button per row navigates to edit form
- ✅ Delete button disabled if type is in use
- ✅ Delete button works if type is not in use
- ✅ Only admin can access
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
**Datei:** `test/mv_web/live/membership_fee_type_live/form_test.exs`
- ✅ Create form works
- ✅ Edit form loads existing type data
- ✅ Interval field editable on create
- ✅ Interval field grayed out on edit
- ✅ Amount change warning displays on edit
- ✅ Amount change warning shows correct affected member count
- ✅ Amount change can be confirmed
- ✅ Amount change can be cancelled
- ✅ Validation errors display correctly
- ✅ Only admin can access
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Member Form Tests
**Datei:** `test/mv_web/member_live/form_membership_fee_type_test.exs`
- ✅ Membership fee type dropdown displays in form
- ✅ Shows available types
- ✅ Filters to same interval types if member has type
- ✅ Warning displayed if different interval selected
- ✅ Warning cleared if same interval selected
- ✅ Form saves with selected membership fee type
- ✅ New members get default membership fee type
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
### Integration Tests
**Datei:** `test/mv_web/member_live/membership_fee_integration_test.exs`
- ✅ End-to-end: Create type → Assign to member → View cycles → Change status
- ✅ End-to-end: Change member type → Cycles regenerate
- ✅ End-to-end: Update settings → New members get default type
- ✅ End-to-end: Delete cycle → Confirmation → Cycle deleted
- ✅ End-to-end: Edit cycle amount → Modal → Amount updated
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
## Test-Ausführung
Alle Tests können mit folgenden Befehlen ausgeführt werden:
```bash
# Alle Tests
mix test
# Nur Membership Fee Tests
mix test test/mv_web/helpers/membership_fee_helpers_test.exs
mix test test/mv_web/member_live/
mix test test/mv_web/live/membership_fee_type_live/
# Mit Coverage
mix test --cover
```
## Bekannte Probleme
Keine bekannten Probleme. Alle Tests sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
## Nächste Schritte
1. ✅ Tests geschrieben
2. ⏳ Tests ausführen und verifizieren
3. ⏳ Eventuelle Anpassungen basierend auf Test-Ergebnissen
4. ⏳ Code-Review durchführen

View file

@ -102,9 +102,6 @@ defmodule Mv.Membership.Member do
where [changing(:user)]
end
# Auto-assign default membership fee type if not explicitly set
change Mv.Membership.Member.Changes.SetDefaultMembershipFeeType
# Auto-calculate membership_fee_start_date if not manually set
# Requires both join_date and membership_fee_type_id to be present
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
@ -242,63 +239,6 @@ defmodule Mv.Membership.Member do
{:ok, member}
end
end)
# Trigger cycle regeneration when join_date or exit_date changes
# Regenerates cycles based on new dates
# Note: Cycle generation runs synchronously in test environment, asynchronously in production
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
change after_action(fn changeset, member, _context ->
join_date_changed = Ash.Changeset.changing_attribute?(changeset, :join_date)
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
# Use skip_lock?: true to avoid nested transactions (after_action runs within action transaction)
# Return notifications to Ash so they are sent after commit
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
skip_lock?: true
) do
{:ok, _cycles, notifications} ->
{:ok, member, notifications}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
{:ok, member}
end
else
# Run asynchronously in other environments
# Send notifications explicitly since they cannot be returned via after_action
Task.start(fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles, notifications} ->
# Send notifications manually for async case
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
{:error, reason} ->
require Logger
Logger.warning(
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
)
end
end)
{:ok, member}
end
else
{:ok, member}
end
end)
end
# Action to handle fuzzy search on specific fields
@ -452,6 +392,11 @@ defmodule Mv.Membership.Member do
end
end
# Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)],
message: "cannot be in the future"
# Exit date not before join date
validate compare(:exit_date, greater_than: :join_date),
where: [present([:join_date, :exit_date])],
@ -509,6 +454,10 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254
end
attribute :paid, :boolean do
allow_nil? true
end
attribute :phone_number, :string do
allow_nil? true
end

View file

@ -1,41 +0,0 @@
defmodule Mv.Membership.Member.Changes.SetDefaultMembershipFeeType do
@moduledoc """
Ash change that automatically assigns the default membership fee type to new members
if no membership_fee_type_id is explicitly provided.
This change reads the default_membership_fee_type_id from global settings and
assigns it to the member if membership_fee_type_id is nil.
"""
use Ash.Resource.Change
def change(changeset, _opts, _context) do
# Only set default if membership_fee_type_id is not already set
current_type_id = Ash.Changeset.get_attribute(changeset, :membership_fee_type_id)
if is_nil(current_type_id) do
apply_default_membership_fee_type(changeset)
else
changeset
end
end
defp apply_default_membership_fee_type(changeset) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
if settings.default_membership_fee_type_id do
Ash.Changeset.force_change_attribute(
changeset,
:membership_fee_type_id,
settings.default_membership_fee_type_id
)
else
changeset
end
{:error, _error} ->
# If settings can't be loaded, continue without default
# This prevents member creation from failing if settings are misconfigured
changeset
end
end
end

View file

@ -49,7 +49,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
update :update do
primary? true
accept [:status, :notes, :amount]
accept [:status, :notes]
end
update :mark_as_paid do

View file

@ -7,6 +7,7 @@ defmodule Mv.Constants do
:first_name,
:last_name,
:email,
:paid,
:phone_number,
:join_date,
:exit_date,

View file

@ -299,15 +299,11 @@ defmodule Mv.MembershipFees.CalendarCycles do
end
defp quarterly_cycle_end(cycle_start) do
# Ensure cycle_start is aligned to quarter boundary
# This handles cases where cycle_start might not be at the correct quarter start (e.g., month 12)
aligned_start = quarterly_cycle_start(cycle_start)
case aligned_start.month do
1 -> Date.new!(aligned_start.year, 3, 31)
4 -> Date.new!(aligned_start.year, 6, 30)
7 -> Date.new!(aligned_start.year, 9, 30)
10 -> Date.new!(aligned_start.year, 12, 31)
case cycle_start.month do
1 -> Date.new!(cycle_start.year, 3, 31)
4 -> Date.new!(cycle_start.year, 6, 30)
7 -> Date.new!(cycle_start.year, 9, 30)
10 -> Date.new!(cycle_start.year, 12, 31)
end
end
@ -317,13 +313,9 @@ defmodule Mv.MembershipFees.CalendarCycles do
end
defp half_yearly_cycle_end(cycle_start) do
# Ensure cycle_start is aligned to half-year boundary
# This handles cases where cycle_start might not be at the correct half-year start (e.g., month 10)
aligned_start = half_yearly_cycle_start(cycle_start)
case aligned_start.month do
1 -> Date.new!(aligned_start.year, 6, 30)
7 -> Date.new!(aligned_start.year, 12, 31)
case cycle_start.month do
1 -> Date.new!(cycle_start.year, 6, 30)
7 -> Date.new!(cycle_start.year, 12, 31)
end
end

View file

@ -386,44 +386,18 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:ok, cycle} ->
{:ok, cycle, []}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{private_vars: %{constraint: constraint, constraint_type: :unique}}]}} = error ->
# Cycle already exists (unique constraint violation) - skip it silently
# This makes the function idempotent and prevents errors on server restart
if constraint == "membership_fee_cycles_unique_cycle_per_member_index" do
{:skip, cycle_start}
else
{:error, {cycle_start, error}}
end
{:error, reason} ->
{:error, {cycle_start, reason}}
end
end)
{successes, skips, errors} =
Enum.reduce(results, {[], [], []}, fn
{:ok, cycle, notifications}, {successes, skips, errors} ->
{[{:ok, cycle, notifications} | successes], skips, errors}
{:skip, cycle_start}, {successes, skips, errors} ->
{successes, [cycle_start | skips], errors}
{:error, error}, {successes, skips, errors} ->
{successes, skips, [error | errors]}
end)
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
all_notifications =
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
if Enum.empty?(errors) do
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
if Enum.any?(skips) do
Logger.debug(
"Skipped #{length(skips)} cycles that already exist for member #{member_id}"
)
end
{:ok, successful_cycles, all_notifications}
else
Logger.warning("Some cycles failed to create: #{inspect(errors)}")

View file

@ -32,9 +32,7 @@ defmodule MvWeb.Layouts.Navbar do
<details>
<summary>{gettext("Contributions")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
</li>
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
<li>
<.link navigate="/membership_fee_settings">
{gettext("Membership Fee Settings")}

View file

@ -1,228 +0,0 @@
defmodule MvWeb.Helpers.MembershipFeeHelpers do
@moduledoc """
Helper functions for membership fee UI components.
Provides formatting and utility functions for displaying membership fee
information in LiveViews and templates.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """
Formats a decimal amount as currency string.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("60.00"))
"60,00 €"
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("5.5"))
"5,50 €"
"""
@spec format_currency(Decimal.t()) :: String.t()
def format_currency(%Decimal{} = amount) do
# Use German format: comma as decimal separator, always 2 decimal places
# Normalize to 2 decimal places
normalized = Decimal.round(amount, 2)
normalized_str =
normalized
|> Decimal.to_string(:normal)
|> String.replace(".", ",")
# Ensure 2 decimal places
case String.split(normalized_str, ",") do
[int_part, dec_part] when byte_size(dec_part) == 1 ->
"#{int_part},#{dec_part}0 €"
[int_part, dec_part] when byte_size(dec_part) == 2 ->
"#{int_part},#{dec_part}"
[int_part] ->
"#{int_part},00 €"
_ ->
"#{normalized_str}"
end
end
@doc """
Formats an interval atom as a translated string.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:monthly)
"Monthly"
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:yearly)
"Yearly"
"""
@spec format_interval(:monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_interval(:monthly), do: gettext("Monthly")
def format_interval(:quarterly), do: gettext("Quarterly")
def format_interval(:half_yearly), do: gettext("Half-yearly")
def format_interval(:yearly), do: gettext("Yearly")
@doc """
Formats a cycle date range as a string.
Calculates the cycle end date from cycle_start and interval, then formats
both dates in European format (dd.mm.yyyy).
## Examples
iex> cycle_start = ~D[2024-01-01]
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :yearly)
"01.01.2024 - 31.12.2024"
iex> cycle_start = ~D[2024-03-01]
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :monthly)
"01.03.2024 - 31.03.2024"
"""
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_cycle_range(cycle_start, interval) do
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
start_str = format_date(cycle_start)
end_str = format_date(cycle_end)
"#{start_str} - #{end_str}"
end
@doc """
Gets the last completed cycle for a member.
Returns the cycle that was most recently completed (ended before today).
Returns `nil` if no completed cycles exist.
## Parameters
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
- `today` - Optional date to use as reference (defaults to today)
## Returns
- `%MembershipFeeCycle{}` if found
- `nil` if no completed cycle exists
## Examples
# Member with cycles from 2023 and 2024, today is 2025-01-15
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
"""
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member, today \\ nil)
def get_last_completed_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
case member.membership_fee_type do
nil ->
nil
fee_type ->
cycles = member.membership_fee_cycles || []
# Get all completed cycles (cycle_end < today)
completed_cycles =
cycles
|> Enum.filter(fn cycle ->
cycle_end = CalendarCycles.calculate_cycle_end(cycle.cycle_start, fee_type.interval)
Date.compare(today, cycle_end) == :gt
end)
# Return the most recent completed cycle (highest cycle_start)
completed_cycles
|> Enum.max_by(& &1.cycle_start, Date, fn -> nil end)
end
end
@doc """
Gets the current cycle for a member.
Returns the cycle that contains today's date.
Returns `nil` if no current cycle exists.
## Parameters
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
- `today` - Optional date to use as reference (defaults to today)
## Returns
- `%MembershipFeeCycle{}` if found
- `nil` if no current cycle exists
## Examples
# Member with cycles, today is 2024-06-15 (within Q2 2024)
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
"""
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member, today \\ nil)
def get_current_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
case member.membership_fee_type do
nil ->
nil
fee_type ->
cycles = member.membership_fee_cycles || []
cycles
|> Enum.filter(fn cycle ->
CalendarCycles.current_cycle?(cycle.cycle_start, fee_type.interval, today)
end)
|> List.first()
end
end
@doc """
Gets the CSS color class for a status badge.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:paid)
"badge-success"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:unpaid)
"badge-error"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:suspended)
"badge-ghost"
"""
@spec status_color(:paid | :unpaid | :suspended) :: String.t()
def status_color(:paid), do: "badge-success"
def status_color(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost"
@doc """
Gets the icon name for a status.
## Examples
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:paid)
"hero-check-circle"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:unpaid)
"hero-x-circle"
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:suspended)
"hero-pause-circle"
"""
@spec status_icon(:paid | :unpaid | :suspended) :: String.t()
def status_icon(:paid), do: "hero-check-circle"
def status_icon(:unpaid), do: "hero-x-circle"
def status_icon(:suspended), do: "hero-pause-circle"
# Private helper function for date formatting
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
end

View file

@ -2,12 +2,11 @@ defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
## Props
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
@ -26,7 +25,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
@ -46,7 +45,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
type="button"
class={[
"btn gap-2",
@cycle_status_filter && "btn-active"
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
@ -55,8 +54,8 @@ defmodule MvWeb.Components.PaymentFilterComponent do
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
<span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
@ -71,22 +70,22 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == nil)}
class={@cycle_status_filter == nil && "active"}
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
{gettext("All payment statuses")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :paid)}
class={@cycle_status_filter == :paid && "active"}
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
@ -99,14 +98,14 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@cycle_status_filter == :unpaid)}
class={@cycle_status_filter == :unpaid && "active"}
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="unpaid"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Unpaid")}
{gettext("Not paid")}
</button>
</li>
</ul>
@ -137,11 +136,11 @@ defmodule MvWeb.Components.PaymentFilterComponent do
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("unpaid"), do: :unpaid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(nil), do: gettext("All payment statuses")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:unpaid), do: gettext("Unpaid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -21,10 +21,6 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def render(assigns) do
# Sort custom fields by name for display only
@ -165,46 +161,42 @@ defmodule MvWeb.MemberLive.Form do
<% end %>
</div>
<%!-- Membership Fee Section --%>
<%!-- Payment Data Section (Mockup) --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div>
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate_membership_fee_type"
value={@form[:membership_fee_type_id].value || ""}
>
<option value="">{gettext("None")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
<.form_section title={gettext("Payment Data")}>
<div role="alert" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" />
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
<div class="flex gap-8">
<div class="w-24">
<label for="mock-contribution" class="label text-sm font-medium">
{gettext("Contribution")}
</label>
<input
type="text"
id="mock-contribution"
value="72 €"
disabled
class="input input-bordered w-full bg-base-200"
/>
</div>
<div class="w-40">
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
<div class="flex gap-3 mt-2">
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
<span class="text-sm">{gettext("monthly")}</span>
</label>
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
<span class="text-sm">{gettext("yearly")}</span>
</label>
</div>
</div>
<div class="w-24 flex items-end">
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
</div>
</div>
</.form_section>
@ -243,15 +235,12 @@ defmodule MvWeb.MemberLive.Form do
member =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
id -> Ash.get!(Mv.Membership.Member, id)
end
page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types
available_fee_types = load_available_fee_types(member)
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
@ -259,8 +248,6 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:initial_custom_field_values, initial_custom_field_values)
|> assign(member: member)
|> assign(:page_title, page_title)
|> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil)
|> assign_form()}
end
@ -269,21 +256,7 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def handle_event("validate", %{"member" => member_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
# Check for interval mismatch if membership_fee_type_id changed
socket = check_interval_change(socket, member_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event(
"validate_membership_fee_type",
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
socket
) do
# Same validation as above, but triggered by select change
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))}
end
def handle_event("save", %{"member" => member_params}, socket) do
@ -375,77 +348,6 @@ defmodule MvWeb.MemberLive.Form do
defp return_path("show", nil), do: ~p"/members"
defp return_path("show", member), do: ~p"/members/#{member.id}"
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
defp load_available_fee_types(member) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees)
# If member has a fee type, filter to same interval
if member && member.membership_fee_type do
Enum.filter(all_types, fn type ->
type.interval == member.membership_fee_type.interval
end)
else
all_types
end
end
# Checks if membership fee type interval changed and updates socket assigns
defp check_interval_change(socket, member_params) do
if Map.has_key?(member_params, "membership_fee_type_id") &&
socket.assigns.member &&
socket.assigns.member.membership_fee_type do
handle_interval_change(socket, member_params["membership_fee_type_id"])
else
socket
end
end
# Handles interval change validation
defp handle_interval_change(socket, new_fee_type_id) do
if new_fee_type_id != "" &&
new_fee_type_id != socket.assigns.member.membership_fee_type_id do
validate_interval_match(socket, new_fee_type_id)
else
assign(socket, :interval_warning, nil)
end
end
# Validates that new fee type has same interval as current
defp validate_interval_match(socket, new_fee_type_id) do
new_fee_type = find_fee_type(socket.assigns.available_fee_types, new_fee_type_id)
if new_fee_type &&
new_fee_type.interval != socket.assigns.member.membership_fee_type.interval do
show_interval_warning(socket, new_fee_type)
else
assign(socket, :interval_warning, nil)
end
end
# Shows interval mismatch warning
defp show_interval_warning(socket, new_fee_type) do
assign(
socket,
:interval_warning,
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(socket.assigns.member.membership_fee_type.interval),
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
)
)
end
defp find_fee_type(fee_types, fee_type_id) do
Enum.find(fee_types, &(&1.id == fee_type_id))
end
# -----------------------------------------------------------------
# Helper Functions for Custom Fields
# -----------------------------------------------------------------

View file

@ -35,7 +35,6 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix()
@ -98,7 +97,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
@ -109,8 +108,6 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
|> assign(:show_current_cycle, false)
|> assign(:membership_fee_status_filter, nil)
# We call handle params to use the query from the URL
{:ok, socket}
@ -171,31 +168,6 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns()}
end
@impl true
def handle_event("toggle_cycle_view", _params, socket) do
new_show_current = !socket.assigns.show_current_cycle
socket =
socket
|> assign(:show_current_cycle, new_show_current)
|> load_members()
|> update_selection_assigns()
# Update URL to reflect cycle view change
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
@ -279,13 +251,7 @@ defmodule MvWeb.MemberLive.Index do
# Build the URL with queries
query_params =
build_query_params(
q,
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@ -302,7 +268,7 @@ defmodule MvWeb.MemberLive.Index do
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:cycle_status_filter, filter)
|> assign(:paid_filter, filter)
|> load_members()
|> update_selection_assigns()
@ -312,8 +278,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.show_current_cycle
filter
)
new_path = ~p"/members?#{query_params}"
@ -427,8 +392,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_show_current_cycle(params)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
@ -537,8 +501,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}"
@ -550,6 +513,16 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
base_params
|> Map.put("query", query_value)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
end
# Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params
@ -562,21 +535,29 @@ defmodule MvWeb.MemberLive.Index do
# Pushes URL with updated field selection
defp push_field_selection_url(socket) do
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
base_params = %{
"sort_field" => field_to_string(socket.assigns.sort_field),
"sort_order" => Atom.to_string(socket.assigns.sort_order)
}
# Include paid_filter if set
base_params =
case socket.assigns.paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
query_params = build_query_params(socket, base_params)
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
end
# Converts field to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
# Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do
# Store in socket for now - actual session persistence would require a controller
@ -585,14 +566,8 @@ defmodule MvWeb.MemberLive.Index do
end
# Builds URL query parameters map including all filter/sort state.
# Converts cycle_status_filter atom to string for URL.
defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
show_current_cycle
) do
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
@ -613,19 +588,11 @@ defmodule MvWeb.MemberLive.Index do
"sort_order" => order_str
}
# Only add cycle_status_filter to URL if it's set
base_params =
case cycle_status_filter do
# Only add paid_filter to URL if it's set
case paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
# Add show_current_cycle if true
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
end
@ -660,12 +627,12 @@ defmodule MvWeb.MemberLive.Index do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, visible_custom_field_ids)
# Load membership fee cycles for status display
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state
# For custom fields, we sort after loading
{query, sort_after_load} =
@ -683,14 +650,6 @@ defmodule MvWeb.MemberLive.Index do
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Apply cycle status filter if set
members =
apply_cycle_status_filter(
members,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Sort in memory if needed (for custom fields)
members =
if sort_after_load do
@ -748,17 +707,22 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Applies cycle status filter to members list.
# Applies payment status filter to the query.
#
# Filter values:
# - nil: No filter, return all members
# - :paid: Only members with paid status in the selected cycle (last or current)
# - :unpaid: Only members with unpaid status in the selected cycle (last or current)
defp apply_cycle_status_filter(members, nil, _show_current), do: members
# - :paid: Only members with paid == true
# - :not_paid: Members with paid == false or paid == nil (not paid)
defp apply_paid_filter(query, nil), do: query
defp apply_cycle_status_filter(members, status, show_current)
when status in [:paid, :unpaid] do
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
defp apply_paid_filter(query, :paid) do
Ash.Query.filter(query, expr(paid == true))
end
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
end
# Functions to toggle sorting order
@ -792,7 +756,7 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field?(field) when is_atom(field) do
# All member fields are sortable, but we exclude some that don't make sense
# :id is not in member_fields, but we don't want to sort by it anyway
non_sortable_fields = [:notes]
non_sortable_fields = [:notes, :paid]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
@ -1063,36 +1027,28 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Updates cycle status filter from URL parameters if present.
# Updates paid filter from URL parameters if present.
#
# Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
filter = determine_cycle_status_filter(filter_str)
assign(socket, :cycle_status_filter, filter)
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
filter = determine_paid_filter(filter_str)
assign(socket, :paid_filter, filter)
end
defp maybe_update_cycle_status_filter(socket, _params) do
defp maybe_update_paid_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :cycle_status_filter, nil)
assign(socket, :paid_filter, nil)
end
# Determines valid cycle status filter from URL parameter.
# Determines valid paid filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to filter functions.
defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
assign(socket, :show_current_cycle, true)
end
defp maybe_update_show_current_cycle(socket, _params) do
socket
end
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
# Ash's security recommendation to never pass untrusted input directly to filters.
defp determine_paid_filter("paid"), do: :paid
defp determine_paid_filter("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil
# -------------------------------------------------------------
# Helper Functions for Custom Field Values

View file

@ -39,37 +39,9 @@
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
cycle_status_filter={@cycle_status_filter}
paid_filter={@paid_filter}
member_count={length(@members)}
/>
<button
type="button"
phx-click="toggle_cycle_view"
class={[
"btn gap-2",
@show_current_cycle && "btn-active"
]}
aria-label={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)
}
title={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)
}
>
<.icon name="hero-arrow-path" class="h-5 w-5" />
<span class="hidden sm:inline">
{if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
else: gettext("Last Cycle Payment Status")
)}
</span>
</button>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
@ -275,39 +247,13 @@
>
{MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col>
<:col
:let={member}
label={
~H"""
<div class="flex items-center gap-2">
<span>{gettext("Membership Fee Status")}</span>
<button
type="button"
phx-click="toggle_cycle_view"
class="btn btn-xs btn-ghost"
title={
if(@show_current_cycle,
do: gettext("Switch to last completed cycle"),
else: gettext("Switch to current cycle")
)
}
>
<.icon name="hero-arrow-path" class="size-3" />
</button>
</div>
"""
}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %>
<span class={["badge", badge.color]}>
<.icon name={badge.icon} class="size-4" />
{badge.label}
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")
]}>
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %>
</:col>
<:action :let={member}>
<div class="sr-only">

View file

@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Show do
use MvWeb, :live_view
import Ash.Query
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def render(assigns) do
~H"""
@ -45,36 +43,16 @@ defmodule MvWeb.MemberLive.Show do
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button
role="tab"
class={[
"tab",
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :contact}
phx-click="switch_tab"
phx-value-tab="contact"
>
<button role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
role="tab"
class={[
"tab",
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :membership_fees}
phx-click="switch_tab"
phx-value-tab="membership_fees"
>
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Membership Fees")}
{gettext("Payments")}
</button>
</div>
<%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
@ -95,12 +73,7 @@ defmodule MvWeb.MemberLive.Show do
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
<.mailto_link email={@member.email} display={@member.email} />
</.data_field>
</div>
@ -153,15 +126,14 @@ defmodule MvWeb.MemberLive.Show do
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@member.custom_field_values) do %>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
<% custom_field = cfv.custom_field %>
<% value_type = custom_field && custom_field.value_type %>
<.data_field label={custom_field && custom_field.name}>
{format_custom_field_value(cfv.value, value_type)}
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %>
</div>
@ -170,111 +142,59 @@ defmodule MvWeb.MemberLive.Show do
<% end %>
</div>
<%!-- Payment Data Section --%>
<div class="w-full">
<%!-- Payment Data Section (Mockup) --%>
<div class="max-w-xl">
<.section_box title={gettext("Payment Data")}>
<%= if @member.membership_fee_type do %>
<div class="flex gap-6 flex-wrap">
<.data_field
label={gettext("Type")}
value={@member.membership_fee_type.name}
class="min-w-32"
/>
<.data_field
label={gettext("Membership Fee")}
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<div role="alert" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" />
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
</div>
<div class="flex gap-6">
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
<.data_field label={gettext("Paid")} class="w-24">
<%= if @member.paid do %>
<span class="badge badge-success">{gettext("Paid")}</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<span class="badge badge-warning">{gettext("Pending")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</.section_box>
</div>
<% 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 %>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_tab, :contact)}
{:ok, socket}
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,
:membership_fee_type,
custom_field_values: [:custom_field],
membership_fee_cycles: [:membership_fee_type]
])
|> load([:user, custom_field_values: [:custom_field]])
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")
@ -318,33 +238,37 @@ 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"""
<a
href={"mailto:#{@email}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@display}
</a>
"""
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]
@ -373,20 +297,31 @@ 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
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(custom_field_values, fn cfv ->
cfv.custom_field_id == custom_field_id or
(cfv.custom_field && cfv.custom_field.id == custom_field_id)
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
@ -396,20 +331,38 @@ defmodule MvWeb.MemberLive.Show do
end
defp format_custom_field_value(value, :email) when is_binary(value) do
if String.trim(value) == "" do
render_empty_value()
else
assigns = %{email: value}
~H"""
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
<.mailto_link email={@email} display={@email} />
"""
end
end
defp format_custom_field_value(value, :integer) when is_integer(value) do
Integer.to_string(value)
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"""
<span class="text-base-content/50 italic">
<span aria-hidden="true"></span>
<span class="sr-only">{@text}</span>
</span>
"""
end
end

View file

@ -1,901 +0,0 @@
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"""
<div id={@id}>
<.section_box title={gettext("Membership Fees")}>
<%!-- Membership Fee Type Display --%>
<div class="mb-6">
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<%= if @member.membership_fee_type do %>
<div class="flex items-center gap-2">
<span class="font-medium">{@member.membership_fee_type.name}</span>
<span class="text-base-content/60">
({MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}, {MembershipFeeHelpers.format_interval(
@member.membership_fee_type.interval
)})
</span>
</div>
<% else %>
<span class="text-base-content/60 italic">
{gettext("No membership fee type assigned")}
</span>
<% end %>
</div>
<%!-- Action Buttons --%>
<div class="flex gap-2 mb-4">
<.button
phx-click="regenerate_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
title={gettext("Generate cycles from the last existing cycle to today")}
>
<.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button>
<.button
:if={Enum.any?(@cycles)}
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
</.button>
<.button
:if={@member.membership_fee_type}
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")}
>
<.icon name="hero-plus" class="size-4" />
{gettext("Create Cycle")}
</.button>
</div>
<%!-- Cycles Table --%>
<%= if Enum.any?(@cycles) do %>
<.table
id="membership-fee-cycles"
rows={@cycles}
row_id={fn cycle -> "cycle-#{cycle.id}" end}
>
<:col :let={cycle} label={gettext("Cycle")}>
{MembershipFeeHelpers.format_cycle_range(
cycle.cycle_start,
cycle.membership_fee_type.interval
)}
</:col>
<:col :let={cycle} label={gettext("Interval")}>
<span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
</span>
</:col>
<:col :let={cycle} label={gettext("Amount")}>
<span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
</:col>
<:col :let={cycle} label={gettext("Status")}>
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
<span class={["badge", badge]}>
<.icon name={icon} class="size-4" />
{format_status_label(cycle.status)}
</span>
</:col>
<:action :let={cycle}>
<div class="flex gap-1">
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</div>
</:action>
</.table>
<% else %>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="size-5" />
<span>
{gettext(
"No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
)}
</span>
</div>
<% end %>
</.section_box>
<%!-- Edit Cycle Amount Modal --%>
<%= if @editing_cycle do %>
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
<form phx-submit="save_cycle_amount" phx-target={@myself}>
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="number"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@editing_cycle.amount)}
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
<%!-- Delete Cycle Confirmation Modal --%>
<%= if @deleting_cycle do %>
<dialog id="delete-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
<p class="py-4">
{gettext("Are you sure you want to delete this cycle?")}
</p>
<p class="text-sm text-base-content/70 mb-4">
{MembershipFeeHelpers.format_cycle_range(
@deleting_cycle.cycle_start,
@deleting_cycle.membership_fee_type.interval
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p>
<div class="modal-action">
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself}
class="btn btn-error"
>
{gettext("Delete")}
</button>
</div>
</div>
</dialog>
<% end %>
<%!-- Delete All Cycles Confirmation Modal --%>
<%= if @deleting_all_cycles do %>
<dialog id="delete-all-cycles-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
<div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<h4 class="font-bold">{gettext("Warning")}</h4>
<p>
{gettext("You are about to delete all %{count} cycles for this member.",
count: length(@cycles)
)}
</p>
<p class="mt-2">
{gettext("This action cannot be undone.")}
</p>
</div>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">
{gettext("Type '%{confirmation}' to confirm", confirmation: gettext("Yes"))}
</span>
</label>
<input
type="text"
phx-keyup="update_delete_all_confirmation"
phx-target={@myself}
value={@delete_all_confirmation || ""}
class="input input-bordered w-full"
placeholder={gettext("Yes")}
/>
</div>
<div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_all_cycles"
phx-target={@myself}
class="btn btn-error"
disabled={
@delete_all_confirmation != gettext("Yes") && @delete_all_confirmation != "Yes"
}
>
{gettext("Delete All")}
</button>
</div>
</div>
</dialog>
<% end %>
<%!-- Create Cycle Modal --%>
<%= if @creating_cycle do %>
<dialog id="create-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4">
<label class="label" for="create-cycle-date">
<span class="label-text">{gettext("Date")}</span>
</label>
<input
type="date"
id="create-cycle-date"
name="date"
value={@create_cycle_date || ""}
phx-change="update_create_cycle_date"
phx-target={@myself}
class="input input-bordered w-full"
required
aria-label={gettext("Date")}
/>
<label class="label">
<span class="label-text-alt">
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle Period")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(
@create_cycle_date,
@member.membership_fee_type.interval
)}
</div>
</div>
<% end %>
<div class="form-control w-full mt-4">
<label class="label" for="create-cycle-amount">
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="number"
id="create-cycle-amount"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@member.membership_fee_type.amount)}
class="input input-bordered w-full"
required
aria-label={gettext("Amount")}
/>
</div>
<%= if @create_cycle_error do %>
<div class="alert alert-error mt-4">
<.icon name="hero-exclamation-circle" class="size-5" />
<span>{@create_cycle_error}</span>
</div>
<% end %>
<div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
</div>
"""
end
@impl true
def update(assigns, socket) do
member = assigns.member
# Load cycles if not already loaded
cycles =
case member.membership_fee_cycles do
nil -> []
cycles when is_list(cycles) -> cycles
_ -> []
end
# Sort cycles by cycle_start descending (newest first)
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member)
{:ok,
socket
|> assign(assigns)
|> assign_new(:cycles, fn -> cycles end)
|> assign_new(:available_fee_types, fn -> available_fee_types end)
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
|> assign_new(: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)
# 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) 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
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)
case Decimal.parse(amount_str) do
{amount, _} when is_struct(amount, Decimal) ->
case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update() do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:editing_cycle, nil)
|> put_flash(:info, gettext("Cycle amount updated"))}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, format_error(error))}
end
:error ->
{:noreply, put_flash(socket, :error, gettext("Invalid amount format"))}
end
end
def handle_event("delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
{:noreply, assign(socket, :deleting_cycle, cycle)}
end
def handle_event("cancel_delete_cycle", _params, socket) do
{:noreply, assign(socket, :deleting_cycle, nil)}
end
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
case Ash.destroy(cycle) do
:ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))}
{: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
member = socket.assigns.member
cycles = socket.assigns.cycles
# Delete all cycles
results =
Enum.map(cycles, fn cycle ->
Ash.destroy(cycle)
end)
# Check if all deletions were successful
errors = Enum.filter(results, &match?({:error, _}, &1))
if Enum.empty?(errors) do
# Reload member to get updated cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
error_msg =
Enum.map_join(errors, ", ", fn {:error, error} -> format_error(error) end)
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Failed to delete some cycles: %{errors}", errors: error_msg))}
end
end
def handle_event("open_create_cycle_modal", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, true)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("cancel_create_cycle", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("update_create_cycle_date", %{"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
with {:ok, date} <- Date.from_iso8601(date_str),
{amount, _} when is_struct(amount, Decimal) <- Decimal.parse(amount_str),
cycle_start <-
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
attrs = %{
cycle_start: cycle_start,
amount: amount,
status: :unpaid,
member_id: member.id,
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
|> put_flash(:info, gettext("Cycle created successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:create_cycle_error, format_error(error))}
end
else
:error ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid date format"))}
{:error, :invalid_amount} ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid amount format"))}
{:error, :cycle_exists} ->
{:noreply,
socket
|> assign(
:create_cycle_error,
gettext("A cycle for this period already exists")
)}
end
end
# Helper functions
defp get_available_fee_types(member) do
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"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
end

View file

@ -30,40 +30,11 @@ defmodule MvWeb.MembershipFeeSettingsLive do
@impl true
def handle_event("validate", %{"settings" => params}, socket) 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
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, normalized_params))}
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, params))}
end
def handle_event("save", %{"settings" => params}, socket) 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
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
{:ok, updated_settings} ->
{:noreply,
socket
@ -130,12 +101,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
)})
</option>
</select>
<%= 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, []} %>
<%= for {msg, _opts} <- @form.errors[:default_membership_fee_type_id] || [] do %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"This membership fee type is automatically assigned to all new members. Can be changed individually per member."
@ -157,12 +125,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{gettext("Include joining cycle")}
</span>
</label>
<%= 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, []} %>
<%= for {msg, _opts} <- @form.errors[:include_joining_cycle] || [] do %>
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
<% end %>
<% end %>
<div class="ml-9 space-y-2">
<p class="text-sm text-base-content/60">
{gettext("When active: Members pay from the cycle of their joining.")}

View file

@ -1,455 +0,0 @@
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"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage membership fee types in your database.")}
</:subtitle>
</.header>
<.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"
/>
<div class="form-control">
<label class="label" for="membership-fee-type-form_interval">
<span class="label-text font-semibold">
{gettext("Interval")}
<span
:if={is_nil(@membership_fee_type)}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>
*
</span>
</span>
</label>
<select
class={[
"select select-bordered w-full",
@form.errors[:interval] && "select-error"
]}
disabled={!is_nil(@membership_fee_type)}
name="membership_fee_type[interval]"
id="membership-fee-type-form_interval"
required={is_nil(@membership_fee_type)}
aria-label={gettext("Interval")}
>
<option value="">{gettext("Select interval")}</option>
<option
value="monthly"
selected={@form[:interval].value == :monthly || @form[:interval].value == "monthly"}
>
{gettext("Monthly")}
</option>
<option
value="quarterly"
selected={@form[:interval].value == :quarterly || @form[:interval].value == "quarterly"}
>
{gettext("Quarterly")}
</option>
<option
value="half_yearly"
selected={
@form[:interval].value == :half_yearly || @form[:interval].value == "half_yearly"
}
>
{gettext("Half-yearly")}
</option>
<option
value="yearly"
selected={@form[:interval].value == :yearly || @form[:interval].value == "yearly"}
>
{gettext("Yearly")}
</option>
</select>
<%= if @form.errors[:interval] do %>
<%= for error <- List.wrap(@form.errors[:interval]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
<%= if !is_nil(@membership_fee_type) do %>
<label class="label">
<span class="label-text-alt text-base-content/60">
{gettext("Interval cannot be changed after creation.")}
</span>
</label>
<% end %>
</div>
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Membership Fee Type")}
</.button>
<.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
{gettext("Cancel")}
</.button>
</div>
</.form>
<%!-- Amount Change Warning Modal --%>
<%= if @show_amount_warning do %>
<dialog id="amount-warning-modal" class="modal modal-open">
<div class="modal-box">
<h2 class="text-lg font-bold">{gettext("Change Amount?")}</h2>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<p class="font-semibold">
{gettext("Changing the amount will affect %{count} member(s).",
count: @affected_member_count
)}
</p>
<p class="mt-2 text-sm">
{gettext("Future unpaid cycles will be regenerated with the new amount.")}
</p>
<p class="mt-2 text-sm">
{gettext("Already paid cycles will remain with the old amount.")}
</p>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-base-content/70">{gettext("Current amount")}:</span>
<span class="font-mono font-semibold">
{MembershipFeeHelpers.format_currency(@old_amount)}
</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">{gettext("New amount")}:</span>
<span class="font-mono font-semibold text-base-content">
{MembershipFeeHelpers.format_currency(@new_amount)}
</span>
</div>
</div>
</div>
<div class="modal-action">
<button
type="button"
phx-click="cancel_amount_change"
class="btn"
>
{gettext("Cancel")}
</button>
<button
type="button"
phx-click="confirm_amount_change"
class="btn btn-primary"
>
{gettext("Confirm Change")}
</button>
</div>
</div>
</dialog>
<% end %>
</Layouts.app>
"""
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 ->
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

View file

@ -1,224 +0,0 @@
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"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Membership Fee Types")}
<:subtitle>
{gettext("Manage membership fee types for membership fees.")}
</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
</.button>
</:actions>
</.header>
<.table
id="membership_fee_types"
rows={@membership_fee_types}
row_id={fn mft -> "mft-#{mft.id}" end}
>
<:col :let={mft} label={gettext("Name")}>
<span class="font-medium">{mft.name}</span>
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
</:col>
<:col :let={mft} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
</:col>
<:col :let={mft} label={gettext("Interval")}>
<span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</span>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
</:col>
<: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" />
</.link>
</:action>
<:action :let={mft}>
<div
:if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left"
data-tip={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
>
<button
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
aria-label={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
disabled={true}
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
<button
:if={get_member_count(mft, @member_counts) == 0}
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete membership fee type")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</:action>
</.table>
<.info_card />
</Layouts.app>
"""
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
fee_type = Ash.get!(MembershipFeeType, id)
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"""
<div class="card bg-base-200 mt-6">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-information-circle" class="size-5" />
{gettext("About Membership Fee Types")}
</h2>
<div class="prose prose-sm max-w-none">
<p>
{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."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
</div>
</div>
"""
end
end

View file

@ -1,179 +0,0 @@
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.
Only loads the relevant cycle per member (last completed or current, depending on show_current).
## Parameters
- `query` - Ash query for members
- `show_current` - If true, load current cycle; if false, load last completed cycle
- `today` - Optional date to use as reference (defaults to today)
## Returns
Modified query with cycles loaded
## Performance
Uses Ash.Query.load to efficiently preload cycles in a single query.
Filters cycles at database level to only load the relevant cycle per member.
"""
@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 with efficient filtering
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()) :: :paid | :unpaid | :suspended | nil
def get_cycle_status_for_member(member, show_current \\ false) do
cycle =
if show_current do
MembershipFeeHelpers.get_current_cycle(member)
else
MembershipFeeHelpers.get_last_completed_cycle(member)
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

View file

@ -72,11 +72,6 @@ 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

View file

@ -20,6 +20,7 @@ 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")

58
notes.md Normal file
View file

@ -0,0 +1,58 @@
# 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.

View file

@ -17,7 +17,6 @@ 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?"
@ -38,8 +37,6 @@ 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/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@ -144,9 +141,10 @@ 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/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
@ -172,7 +170,6 @@ 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..."
@ -186,6 +183,7 @@ 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
@ -198,9 +196,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"
@ -258,8 +256,6 @@ 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"
@ -272,7 +268,6 @@ 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"
@ -307,7 +302,6 @@ 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"
@ -315,8 +309,6 @@ 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"
@ -784,7 +776,6 @@ 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"
@ -794,6 +785,11 @@ 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"
@ -811,6 +807,7 @@ 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"
@ -821,21 +818,40 @@ 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
@ -850,11 +866,27 @@ 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"
@ -874,9 +906,6 @@ 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"
@ -887,7 +916,6 @@ 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."
@ -907,6 +935,7 @@ 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"
@ -938,7 +967,6 @@ 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"
@ -954,7 +982,6 @@ 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."
@ -964,11 +991,9 @@ 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"
@ -986,9 +1011,6 @@ 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"
@ -1058,11 +1080,9 @@ 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"
@ -1073,7 +1093,6 @@ 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"
@ -1089,7 +1108,6 @@ 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."
@ -1110,11 +1128,9 @@ 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"
@ -1152,7 +1168,6 @@ 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"
@ -1173,9 +1188,6 @@ 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"
@ -1196,11 +1208,7 @@ 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"
@ -1210,11 +1218,9 @@ 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"
@ -1235,7 +1241,6 @@ 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"
@ -1296,7 +1301,6 @@ 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"
@ -1418,408 +1422,10 @@ 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/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/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 "Zyklus 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 "Failed to delete some cycles: %{errors}"
msgstr "Konnte Feld nicht löschen: %{error}"
#: 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/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"
#~ msgstr "Jeder Zahlungs-Zustand"
msgid "Not set"
msgstr "Nicht gesetzt"
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
@ -1831,32 +1437,47 @@ msgstr "Letzter Zyklus Zahlungsstatus"
#~ 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/member_live/show/membership_fees_component.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Edit amount"
#~ msgstr "Betrag bearbeiten"
#~ msgid "Example: Member Contribution View"
#~ msgstr "Beispiel: Ansicht Mitgliedsbeiträge"
#~ #: 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/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
@ -1868,85 +1489,52 @@ msgstr "Letzter Zyklus Zahlungsstatus"
#~ 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/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr "Nicht bezahlt"
#~ #: lib/mv_web/live/user_live/form.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not set"
#~ msgstr "Nicht gesetzt"
#~ #: 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/member_live/index.html.heex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgstr ""
#~ 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."
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ 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/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"
#~ 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."
#~ #: 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"

View file

@ -18,7 +18,6 @@ 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?"
@ -39,8 +38,6 @@ 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/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@ -145,9 +142,10 @@ 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/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -173,7 +171,6 @@ 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..."
@ -187,6 +184,7 @@ 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
@ -199,9 +197,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 ""
@ -259,8 +257,6 @@ 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"
@ -273,7 +269,6 @@ 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 ""
@ -308,7 +303,6 @@ 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 ""
@ -316,8 +310,6 @@ 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 ""
@ -785,7 +777,6 @@ 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 ""
@ -795,6 +786,11 @@ 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"
@ -812,6 +808,7 @@ 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 ""
@ -822,21 +819,40 @@ 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
@ -851,11 +867,27 @@ 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"
@ -875,9 +907,6 @@ 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 ""
@ -888,7 +917,6 @@ 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 ""
@ -908,6 +936,7 @@ 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"
@ -939,7 +968,6 @@ 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 ""
@ -955,7 +983,6 @@ 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 ""
@ -965,11 +992,9 @@ 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 ""
@ -987,9 +1012,6 @@ 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 ""
@ -1059,11 +1081,9 @@ 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 ""
@ -1074,7 +1094,6 @@ 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 ""
@ -1090,7 +1109,6 @@ 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 ""
@ -1111,11 +1129,9 @@ 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 ""
@ -1153,7 +1169,6 @@ 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 ""
@ -1174,9 +1189,6 @@ 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 ""
@ -1197,11 +1209,7 @@ 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 ""
@ -1211,11 +1219,9 @@ 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 ""
@ -1236,7 +1242,6 @@ 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 ""
@ -1297,7 +1302,6 @@ 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"
@ -1419,400 +1423,7 @@ 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
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/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/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 "Failed to delete some cycles: %{errors}"
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"
msgid "Not set"
msgstr ""

View file

@ -18,7 +18,6 @@ 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?"
@ -39,8 +38,6 @@ 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/membership_fee_type_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
@ -145,9 +142,10 @@ 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/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -173,7 +171,6 @@ 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..."
@ -187,6 +184,7 @@ 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
@ -199,9 +197,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 ""
@ -259,8 +257,6 @@ 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"
@ -273,7 +269,6 @@ 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 ""
@ -308,7 +303,6 @@ 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 ""
@ -316,8 +310,6 @@ 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 ""
@ -785,7 +777,6 @@ 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 ""
@ -795,6 +786,11 @@ 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"
@ -812,6 +808,7 @@ 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 ""
@ -822,21 +819,40 @@ 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
@ -851,11 +867,27 @@ 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"
@ -875,9 +907,6 @@ 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 ""
@ -888,7 +917,6 @@ 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 ""
@ -908,6 +936,7 @@ 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"
@ -939,7 +968,6 @@ 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 ""
@ -955,7 +983,6 @@ 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 ""
@ -965,11 +992,9 @@ 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 ""
@ -987,9 +1012,6 @@ 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 ""
@ -1059,11 +1081,9 @@ 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 ""
@ -1074,7 +1094,6 @@ 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 ""
@ -1090,7 +1109,6 @@ 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 ""
@ -1111,11 +1129,9 @@ 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 ""
@ -1153,7 +1169,6 @@ 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 ""
@ -1174,9 +1189,6 @@ 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 ""
@ -1197,11 +1209,7 @@ 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 ""
@ -1211,11 +1219,9 @@ 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 ""
@ -1236,7 +1242,6 @@ 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 ""
@ -1297,7 +1302,6 @@ 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"
@ -1419,409 +1423,11 @@ 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"
msgid "Not set"
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/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/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 "Failed to delete some cycles: %{errors}"
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/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)"
@ -1832,12 +1438,6 @@ msgstr "Last Cycle Payment Status"
#~ 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
@ -1854,15 +1454,15 @@ msgstr "Last Cycle Payment Status"
#~ msgid "Copy emails"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type"
#~ msgid "Custom Field Values"
#~ 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"
#~ msgid "Default Contribution Type"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
@ -1875,8 +1475,7 @@ msgstr "Last Cycle Payment Status"
#~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Generated periods"
#~ msgstr ""
@ -1891,65 +1490,29 @@ msgstr "Last Cycle Payment Status"
#~ 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/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Not set"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ 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
#~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Show Last/Current Cycle Payment Status"
#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ 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/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"
#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
@ -1957,18 +1520,22 @@ msgstr "Last Cycle Payment Status"
#~ 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 ""

View file

@ -1,21 +0,0 @@
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

View file

@ -6,7 +6,6 @@
alias Mv.Membership
alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
# Create example membership fee types
for fee_type_attrs <- [
@ -128,153 +127,60 @@ 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
# 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 = [
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",
membership_fee_type_id: Enum.at(all_fee_types, 0).id,
cycle_status: :all_paid
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",
membership_fee_type_id: Enum.at(all_fee_types, 1).id,
cycle_status: :all_unpaid
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",
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
cycle_status: :mixed
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"
# 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)
] do
# 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_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
Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email)
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 = [
%{email: "hans.mueller@example.de"},
@ -298,6 +204,7 @@ 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",
@ -312,6 +219,7 @@ 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",
@ -324,85 +232,25 @@ linked_members = [
]
# Create the linked members - use upsert to prevent duplicates
# 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} ->
Enum.each(linked_members, fn member_attrs ->
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
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}),
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_fee_type,
Membership.create_member!(member_attrs_without_user,
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)
# Create sample custom field values for some members

View file

@ -1,132 +0,0 @@
{
"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"
}

View file

@ -1,233 +0,0 @@
{
"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"
}

View file

@ -6,6 +6,7 @@ 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],
@ -41,6 +42,14 @@ 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)
@ -49,9 +58,12 @@ defmodule Mv.Membership.MemberTest do
assert {:ok, _member} = Membership.create_member(attrs2)
end
test "Join date can be in the future" do
test "Join date is optional but must not be in the future" do
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
assert {:ok, _member} = Membership.create_member(attrs)
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)
end
test "Exit date is optional but must not be before join date if both are specified" do

View file

@ -3,7 +3,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :unpaid)
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- 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?cycle_status_filter=paid")
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with unpaid filter active", %{conn: conn} do
test "renders with not_paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
# 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?cycle_status_filter=paid")
{:ok, view, _html} = live(conn, "/members?paid_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 cycle_status_filter param - wait for patch
# URL should not contain paid_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 cycle_status_filter=paid
# Wait for patch and check URL contains paid_filter=paid
path = assert_patch(view)
assert path =~ "cycle_status_filter=paid"
assert path =~ "paid_filter=paid"
end
test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
test "selecting 'Not paid' 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 "Unpaid" option
# Select "Not paid" option
view
|> element("#payment-filter button[phx-value-filter='unpaid']")
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> render_click()
# Wait for patch and check URL contains cycle_status_filter=unpaid
# Wait for patch and check URL contains paid_filter=not_paid
path = assert_patch(view)
assert path =~ "cycle_status_filter=unpaid"
assert path =~ "paid_filter=not_paid"
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?cycle_status_filter=paid")
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view

View file

@ -1,260 +0,0 @@
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

View file

@ -1,218 +0,0 @@
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

View file

@ -1,151 +0,0 @@
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

View file

@ -1,167 +0,0 @@
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

View file

@ -1,368 +0,0 @@
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
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 "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

View file

@ -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

View file

@ -1,261 +0,0 @@
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

View file

@ -457,204 +457,220 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
describe "cycle status filter" do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
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
})
# 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
}
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
})
attrs = Map.merge(default_attrs, attrs)
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
})
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
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
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
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)
{:ok, _view, html} = live(conn, "/members")
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
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=paid")
assert html =~ "PaidLast"
refute html =~ "UnpaidLast"
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
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
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)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
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"
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
end
test "filter shows only members with paid status in current cycle", %{conn: conn} do
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)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
# 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"
refute html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
test "filter combines with search query (AND)", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
# 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"
assert html =~ paid_member.first_name
end
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
test "filter combines with sorting", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with last cycle view and paid filter
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Toggle to current cycle - this should update URL and preserve filter
# Use the button in the membership fee status column header
# Click on email sort header
view
|> element("button[phx-click='toggle_cycle_view'].btn-xs")
|> element("[data-testid='email']")
|> render_click()
# Wait for patch to complete
# Filter should be preserved in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "sort_field=email"
end
# URL should contain both filter and show_current_cycle
assert path =~ "cycle_status_filter=paid"
assert path =~ "show_current_cycle=true"
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"
end
end
end

View file

@ -1,237 +0,0 @@
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

View file

@ -1,270 +0,0 @@
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

View file

@ -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

View file

@ -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

View file

@ -1,8 +1,6 @@
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
@ -44,76 +42,5 @@ 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