Merge branch 'main' into bugfix/274_required_custom_fields
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
08f563a412
76 changed files with 13847 additions and 1688 deletions
|
|
@ -32,9 +32,13 @@ 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="/contribution_types">{gettext("Contribution Types")}</.link></li>
|
||||
<li>
|
||||
<.link navigate="/contribution_settings">{gettext("Contribution Settings")}</.link>
|
||||
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate="/membership_fee_settings">
|
||||
{gettext("Membership Fee Settings")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
|
|
|||
241
lib/mv_web/helpers/membership_fee_helpers.ex
Normal file
241
lib/mv_web/helpers/membership_fee_helpers.ex
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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
|
||||
normalized = Decimal.round(amount, 2)
|
||||
normalized_str = Decimal.to_string(normalized, :normal)
|
||||
|
||||
format_currency_parts(normalized_str)
|
||||
end
|
||||
|
||||
# Formats currency string with comma as decimal separator
|
||||
defp format_currency_parts(normalized_str) do
|
||||
case String.split(normalized_str, ".") do
|
||||
[int_part, dec_part] ->
|
||||
format_with_decimal_part(int_part, dec_part)
|
||||
|
||||
[int_part] ->
|
||||
"#{int_part},00 €"
|
||||
|
||||
_ ->
|
||||
# Fallback for unexpected split results
|
||||
"#{String.replace(normalized_str, ".", ",")} €"
|
||||
end
|
||||
end
|
||||
|
||||
# Formats currency with decimal part, ensuring exactly 2 decimal places
|
||||
defp format_with_decimal_part(int_part, dec_part) do
|
||||
dec_size = byte_size(dec_part)
|
||||
|
||||
formatted_dec =
|
||||
cond do
|
||||
dec_size == 1 -> "#{dec_part}0"
|
||||
dec_size == 2 -> dec_part
|
||||
dec_size > 2 -> String.slice(dec_part, 0, 2)
|
||||
true -> "00"
|
||||
end
|
||||
|
||||
"#{int_part},#{formatted_dec} €"
|
||||
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)
|
||||
|> Enum.sort_by(& &1.cycle_start, {:desc, Date})
|
||||
|> 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
|
||||
|
|
@ -2,11 +2,12 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
@moduledoc """
|
||||
Provides the PaymentFilter Live-Component.
|
||||
|
||||
A dropdown filter for filtering members by payment status (paid/not paid/all).
|
||||
A dropdown filter for filtering members by cycle payment status (paid/unpaid/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
|
||||
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
|
||||
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
|
||||
- `:id` - Component ID (required)
|
||||
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:paid_filter, assigns[:paid_filter])
|
||||
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -45,7 +46,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
type="button"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
@paid_filter && "btn-active"
|
||||
@cycle_status_filter && "btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
|
|
@ -54,8 +55,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(@paid_filter)}</span>
|
||||
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
|
|
@ -70,22 +71,22 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == nil)}
|
||||
class={@paid_filter == nil && "active"}
|
||||
aria-checked={to_string(@cycle_status_filter == nil)}
|
||||
class={@cycle_status_filter == nil && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter=""
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4" />
|
||||
{gettext("All payment statuses")}
|
||||
{gettext("All")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :paid)}
|
||||
class={@paid_filter == :paid && "active"}
|
||||
aria-checked={to_string(@cycle_status_filter == :paid)}
|
||||
class={@cycle_status_filter == :paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="paid"
|
||||
phx-target={@myself}
|
||||
|
|
@ -98,14 +99,14 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :not_paid)}
|
||||
class={@paid_filter == :not_paid && "active"}
|
||||
aria-checked={to_string(@cycle_status_filter == :unpaid)}
|
||||
class={@cycle_status_filter == :unpaid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="not_paid"
|
||||
phx-value-filter="unpaid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
|
||||
{gettext("Not paid")}
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -136,11 +137,11 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
|
||||
# Parse filter string to atom
|
||||
defp parse_filter("paid"), do: :paid
|
||||
defp parse_filter("not_paid"), do: :not_paid
|
||||
defp parse_filter("unpaid"), do: :unpaid
|
||||
defp parse_filter(_), do: nil
|
||||
|
||||
# Get display label for current filter
|
||||
defp filter_label(nil), do: gettext("All payment statuses")
|
||||
defp filter_label(nil), do: gettext("All")
|
||||
defp filter_label(:paid), do: gettext("Paid")
|
||||
defp filter_label(:not_paid), do: gettext("Not paid")
|
||||
defp filter_label(:unpaid), do: gettext("Unpaid")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ defmodule MvWeb.ContributionPeriodLive.Show do
|
|||
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.link navigate={~p"/contribution_settings"} class="btn btn-ghost btn-sm">
|
||||
<.link navigate={~p"/membership_fee_settings"} class="btn btn-ghost btn-sm">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back to Settings")}
|
||||
</.link>
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
defmodule MvWeb.ContributionSettingsLive do
|
||||
@moduledoc """
|
||||
Mock-up LiveView for Contribution Settings (Admin).
|
||||
|
||||
This is a preview-only page that displays the planned UI for managing
|
||||
global contribution settings. It shows static mock data and is not functional.
|
||||
|
||||
## Planned Features (Future Implementation)
|
||||
- Set default contribution type for new members
|
||||
- Configure whether joining period is included in contributions
|
||||
- Explanatory text with examples
|
||||
|
||||
## Settings
|
||||
- `default_contribution_type_id` - UUID of the default contribution type
|
||||
- `include_joining_period` - Boolean whether to include joining period
|
||||
|
||||
## Note
|
||||
This page is intentionally non-functional and serves as a UI mockup
|
||||
for the upcoming Membership Contributions feature.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Contribution Settings"))
|
||||
|> assign(:contribution_types, mock_contribution_types())
|
||||
|> assign(:selected_type_id, "1")
|
||||
|> assign(:include_joining_period, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contribution Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings for membership contributions.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<%!-- Settings Form --%>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" />
|
||||
{gettext("Global Settings")}
|
||||
</h2>
|
||||
|
||||
<form class="space-y-6">
|
||||
<%!-- Default Contribution Type --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Default Contribution Type")}
|
||||
</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full" disabled>
|
||||
<option :for={ct <- @contribution_types} selected={ct.id == @selected_type_id}>
|
||||
{ct.name} ({format_currency(ct.amount)}, {format_interval(ct.interval)})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{gettext(
|
||||
"This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<%!-- Include Joining Period --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={@include_joining_period}
|
||||
disabled
|
||||
/>
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Include joining period")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="ml-9 space-y-2">
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When active: Members pay from the period of their joining.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When inactive: Members pay from the next full period after joining.")}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button type="button" class="btn btn-primary w-full" disabled>
|
||||
<.icon name="hero-check" class="size-5" />
|
||||
{gettext("Save Settings")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Examples Card --%>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</h2>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Period Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Period Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Period Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Period Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.example_member_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example member card with link to period view
|
||||
defp example_member_card(assigns) do
|
||||
~H"""
|
||||
<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-user" class="size-5" />
|
||||
{gettext("Example: Member Contribution View")}
|
||||
</h2>
|
||||
<p class="text-base-content/70">
|
||||
{gettext(
|
||||
"See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||
)}
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm">
|
||||
<.icon name="hero-eye" class="size-4" />
|
||||
{gettext("View Example Member")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock-up warning banner component - subtle orange style
|
||||
defp mockup_warning(assigns) do
|
||||
~H"""
|
||||
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
– {gettext("This page is not functional and only displays the planned features.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example section component
|
||||
attr :title, :string, required: true
|
||||
attr :joining_date, :string, required: true
|
||||
attr :include_joining, :boolean, required: true
|
||||
attr :start_date, :string, required: true
|
||||
attr :periods, :list, required: true
|
||||
attr :note, :string, required: true
|
||||
|
||||
defp example_section(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold text-sm">{@title}</h3>
|
||||
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Joining date")}:</span>
|
||||
<span class="font-mono">{@joining_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Contribution start")}:</span>
|
||||
<span class="font-mono font-semibold text-primary">{@start_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Generated periods")}:</span>
|
||||
<span class="font-mono">
|
||||
{Enum.join(@periods, ", ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 italic">→ {@note}</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock data for demonstration
|
||||
defp mock_contribution_types do
|
||||
[
|
||||
%{
|
||||
id: "1",
|
||||
name: gettext("Regular"),
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
%{
|
||||
id: "2",
|
||||
name: gettext("Reduced"),
|
||||
amount: Decimal.new("30.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
%{
|
||||
id: "3",
|
||||
name: gettext("Student"),
|
||||
amount: Decimal.new("5.00"),
|
||||
interval: :monthly
|
||||
},
|
||||
%{
|
||||
id: "4",
|
||||
name: gettext("Family"),
|
||||
amount: Decimal.new("25.00"),
|
||||
interval: :quarterly
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp format_currency(%Decimal{} = amount) do
|
||||
"#{Decimal.to_string(amount)} €"
|
||||
end
|
||||
|
||||
defp format_interval(:monthly), do: gettext("Monthly")
|
||||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
end
|
||||
|
|
@ -21,6 +21,10 @@ 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
|
||||
|
|
@ -162,42 +166,46 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<%!-- Membership Fee Section --%>
|
||||
<div class="max-w-xl">
|
||||
<.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>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div class="w-24">
|
||||
<label for="mock-contribution" class="label text-sm font-medium">
|
||||
{gettext("Contribution")}
|
||||
<.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>
|
||||
<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" />
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</.form_section>
|
||||
|
|
@ -236,12 +244,15 @@ defmodule MvWeb.MemberLive.Form do
|
|||
member =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.Member, id)
|
||||
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
|
||||
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"]))
|
||||
|
|
@ -249,6 +260,8 @@ 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
|
||||
|
||||
|
|
@ -257,7 +270,21 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"member" => member_params}, socket) do
|
||||
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))}
|
||||
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)
|
||||
end
|
||||
|
||||
def handle_event("save", %{"member" => member_params}, socket) do
|
||||
|
|
@ -349,6 +376,77 @@ 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
|
||||
# -----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ 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()
|
||||
|
|
@ -97,7 +98,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:paid_filter, nil)
|
||||
|> assign(:cycle_status_filter, nil)
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|
|
@ -108,6 +109,8 @@ 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}
|
||||
|
|
@ -168,6 +171,31 @@ 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
|
||||
|
|
@ -251,7 +279,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Build the URL with queries
|
||||
query_params =
|
||||
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
|
||||
build_query_params(
|
||||
q,
|
||||
existing_field_query,
|
||||
existing_sort_query,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
# Set the new path with params
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -268,7 +302,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
def handle_info({:payment_filter_changed, filter}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:paid_filter, filter)
|
||||
|> assign(:cycle_status_filter, filter)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
|
|
@ -278,7 +312,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
filter
|
||||
filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -392,7 +427,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_paid_filter(params)
|
||||
|> maybe_update_cycle_status_filter(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|
|
@ -501,7 +537,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.query,
|
||||
field_str,
|
||||
Atom.to_string(order),
|
||||
socket.assigns.paid_filter
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -513,16 +550,6 @@ 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
|
||||
|
||||
|
|
@ -535,29 +562,21 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Pushes URL with updated field selection
|
||||
defp push_field_selection_url(socket) do
|
||||
base_params = %{
|
||||
"sort_field" => field_to_string(socket.assigns.sort_field),
|
||||
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
||||
}
|
||||
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])
|
||||
|
||||
# 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
|
||||
|
|
@ -566,8 +585,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
# Builds URL query parameters map including all filter/sort state.
|
||||
# Converts paid_filter atom to string for URL.
|
||||
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
||||
# 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
|
||||
field_str =
|
||||
if is_atom(sort_field) do
|
||||
Atom.to_string(sort_field)
|
||||
|
|
@ -588,11 +613,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
"sort_order" => order_str
|
||||
}
|
||||
|
||||
# Only add paid_filter to URL if it's set
|
||||
case paid_filter do
|
||||
nil -> base_params
|
||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
||||
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
||||
# Only add cycle_status_filter to URL if it's set
|
||||
base_params =
|
||||
case cycle_status_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
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -627,12 +660,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} =
|
||||
|
|
@ -650,6 +683,14 @@ 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
|
||||
|
|
@ -707,22 +748,17 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Applies payment status filter to the query.
|
||||
# Applies cycle status filter to members list.
|
||||
#
|
||||
# Filter values:
|
||||
# - nil: No filter, return all 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
|
||||
# - :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
|
||||
|
||||
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)))
|
||||
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)
|
||||
end
|
||||
|
||||
# Functions to toggle sorting order
|
||||
|
|
@ -756,7 +792,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, :paid]
|
||||
non_sortable_fields = [:notes]
|
||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||
|
||||
field in valid_fields or custom_field_sort?(field)
|
||||
|
|
@ -1027,28 +1063,36 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
end
|
||||
|
||||
# Updates paid filter from URL parameters if present.
|
||||
# Updates cycle status filter from URL parameters if present.
|
||||
#
|
||||
# Validates the filter value, falling back to nil (no filter) if invalid.
|
||||
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
|
||||
filter = determine_paid_filter(filter_str)
|
||||
assign(socket, :paid_filter, filter)
|
||||
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)
|
||||
end
|
||||
|
||||
defp maybe_update_paid_filter(socket, _params) do
|
||||
defp maybe_update_cycle_status_filter(socket, _params) do
|
||||
# Reset filter if not in URL params
|
||||
assign(socket, :paid_filter, nil)
|
||||
assign(socket, :cycle_status_filter, nil)
|
||||
end
|
||||
|
||||
# Determines valid paid filter from URL parameter.
|
||||
# Determines valid cycle status filter from URL parameter.
|
||||
#
|
||||
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
|
||||
# SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
|
||||
# are accepted - all other input (including malicious strings) falls back to nil.
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Helper Functions for Custom Field Values
|
||||
|
|
|
|||
|
|
@ -39,9 +39,37 @@
|
|||
<.live_component
|
||||
module={MvWeb.Components.PaymentFilterComponent}
|
||||
id="payment-filter"
|
||||
paid_filter={@paid_filter}
|
||||
cycle_status_filter={@cycle_status_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"
|
||||
|
|
@ -247,13 +275,20 @@
|
|||
>
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<: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>
|
||||
<:col
|
||||
:let={member}
|
||||
label={gettext("Membership Fee Status")}
|
||||
>
|
||||
<%= 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}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -43,156 +45,243 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||
<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"
|
||||
>
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
|
||||
<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"
|
||||
>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Payments")}
|
||||
{gettext("Membership Fees")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
|
||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
||||
</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 --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
|
||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
||||
</div>
|
||||
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- 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>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- 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>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@member.custom_field_values) 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)}
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.section_box 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>
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= 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>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
</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-warning">{gettext("Pending")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
<%!-- Payment Data Section --%>
|
||||
<div class="w-full">
|
||||
<.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>
|
||||
<% 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>
|
||||
<% 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, socket}
|
||||
{:ok, assign(socket, :active_tab, :contact)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
# Load custom fields once using assign_new to avoid repeated queries
|
||||
socket =
|
||||
assign_new(socket, :custom_fields, fn ->
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
end)
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> filter(id == ^id)
|
||||
|> load([:user, custom_field_values: [:custom_field]])
|
||||
|> load([
|
||||
:user,
|
||||
:membership_fee_type,
|
||||
custom_field_values: [:custom_field],
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
member = Ash.read_one!(query)
|
||||
|
||||
# Calculate last and current cycle status from loaded cycles
|
||||
last_cycle_status = get_last_cycle_status(member)
|
||||
current_cycle_status = get_current_cycle_status(member)
|
||||
|
||||
member =
|
||||
member
|
||||
|> Map.put(:last_cycle_status, last_cycle_status)
|
||||
|> Map.put(:current_cycle_status, current_cycle_status)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:member, member)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
|
||||
{:noreply, assign(socket, :active_tab, :contact)}
|
||||
end
|
||||
|
||||
def handle_event("switch_tab", %{"tab" => "membership_fees"}, socket) do
|
||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
|
|
@ -236,14 +325,56 @@ defmodule MvWeb.MemberLive.Show do
|
|||
"""
|
||||
end
|
||||
|
||||
# Renders a mailto link if email is present, otherwise renders empty value placeholder
|
||||
attr :email, :string, required: true
|
||||
attr :display, :string, default: nil
|
||||
|
||||
defp mailto_link(assigns) do
|
||||
display_text = assigns.display || assigns.email
|
||||
|
||||
if assigns.email && String.trim(assigns.email) != "" do
|
||||
assigns = %{email: assigns.email, display: display_text}
|
||||
|
||||
~H"""
|
||||
<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]
|
||||
|
|
@ -272,20 +403,34 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp format_date(date), do: to_string(date)
|
||||
|
||||
# Sorts custom field values by custom field name
|
||||
defp sort_custom_field_values(custom_field_values) do
|
||||
Enum.sort_by(custom_field_values, fn cfv ->
|
||||
(cfv.custom_field && cfv.custom_field.name) || ""
|
||||
# Finds custom field value for a given custom field id
|
||||
# Returns the value (not the CustomFieldValue struct) or nil
|
||||
defp find_custom_field_value(nil, _custom_field_id), do: nil
|
||||
|
||||
defp find_custom_field_value(custom_field_values, custom_field_id)
|
||||
when is_list(custom_field_values) do
|
||||
Enum.find_value(custom_field_values, fn cfv ->
|
||||
if cfv.custom_field_id == custom_field_id or
|
||||
(cfv.custom_field && cfv.custom_field.id == custom_field_id) do
|
||||
cfv.value
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_custom_field_value(_custom_field_values, _custom_field_id), do: nil
|
||||
|
||||
# Formats custom field value based on type
|
||||
# Handles both CustomFieldValue structs and direct values
|
||||
defp format_custom_field_value(nil, _type), do: render_empty_value()
|
||||
|
||||
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
|
||||
format_custom_field_value(cfv.value, value_type)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
|
||||
format_custom_field_value(value, type)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(nil, _type), do: "—"
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
|
||||
if value, do: gettext("Yes"), else: gettext("No")
|
||||
end
|
||||
|
|
@ -295,11 +440,15 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
assigns = %{email: value}
|
||||
if String.trim(value) == "" do
|
||||
render_empty_value()
|
||||
else
|
||||
assigns = %{email: value}
|
||||
|
||||
~H"""
|
||||
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
|
||||
"""
|
||||
~H"""
|
||||
<.mailto_link email={@email} display={@email} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_integer(value) do
|
||||
|
|
@ -307,8 +456,22 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
if String.trim(value) == "", do: "—", else: value
|
||||
if String.trim(value) == "", do: render_empty_value(), else: value
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||
|
||||
# Renders accessible placeholder for empty values
|
||||
# Uses translated text for screen readers while maintaining visual consistency
|
||||
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
|
||||
defp render_empty_value do
|
||||
assigns = %{text: gettext("Not set")}
|
||||
|
||||
~H"""
|
||||
<span class="text-base-content/50 italic">
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="sr-only">{@text}</span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
927
lib/mv_web/live/member_live/show/membership_fees_component.ex
Normal file
927
lib/mv_web/live/member_live/show/membership_fees_component.ex
Normal file
|
|
@ -0,0 +1,927 @@
|
|||
defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for displaying and managing membership fees for a member.
|
||||
|
||||
## Features
|
||||
- Display all membership fee cycles in a table
|
||||
- Change membership fee type (with same-interval validation)
|
||||
- Change cycle status (paid/unpaid/suspended)
|
||||
- Regenerate cycles manually
|
||||
- Delete cycles (with confirmation)
|
||||
- Edit cycle amount (with modal)
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<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="text"
|
||||
inputmode="decimal"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
|
||||
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={
|
||||
String.trim(String.downcase(@delete_all_confirmation)) !=
|
||||
String.downcase(gettext("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="text"
|
||||
inputmode="decimal"
|
||||
id="create-cycle-amount"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={
|
||||
Decimal.to_string(@member.membership_fee_type.amount) |> String.replace(".", ",")
|
||||
}
|
||||
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(:cycles, cycles)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign_new(:interval_warning, fn -> nil end)
|
||||
|> assign_new(:editing_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_all_cycles, fn -> false end)
|
||||
|> assign_new(:delete_all_confirmation, fn -> "" end)
|
||||
|> assign_new(:creating_cycle, fn -> false end)
|
||||
|> assign_new(:create_cycle_date, fn -> nil end)
|
||||
|> assign_new(:create_cycle_error, fn -> nil end)
|
||||
|> assign_new(:regenerating, fn -> false end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
|
||||
# Remove membership fee type
|
||||
case update_member_fee_type(socket.assigns.member, nil) do
|
||||
{:ok, updated_member} ->
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, [])
|
||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
|
||||
member = socket.assigns.member
|
||||
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
|
||||
|
||||
# Check if interval matches
|
||||
interval_warning =
|
||||
if member.membership_fee_type &&
|
||||
member.membership_fee_type.interval != new_fee_type.interval do
|
||||
gettext(
|
||||
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
|
||||
old_interval: MembershipFeeHelpers.format_interval(member.membership_fee_type.interval),
|
||||
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
if interval_warning do
|
||||
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
||||
else
|
||||
case update_member_fee_type(member, fee_type_id) do
|
||||
{:ok, updated_member} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
updated_member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("mark_cycle_status", %{"cycle_id" => cycle_id, "status" => status_str}, socket) do
|
||||
status = String.to_existing_atom(status_str)
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
action =
|
||||
case status do
|
||||
:paid -> :mark_as_paid
|
||||
:unpaid -> :mark_as_unpaid
|
||||
:suspended -> :mark_as_suspended
|
||||
end
|
||||
|
||||
case Ash.update(cycle, action: action, domain: MembershipFees) do
|
||||
{:ok, updated_cycle} ->
|
||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> put_flash(:info, gettext("Cycle status updated"))}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
error_msg =
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Failed to update cycle status: %{errors}", errors: error_msg)
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("regenerate_cycles", _params, socket) do
|
||||
socket = assign(socket, :regenerating, true)
|
||||
member = socket.assigns.member
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("edit_cycle_amount", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Load cycle with membership_fee_type for display
|
||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
||||
|
||||
{:noreply, assign(socket, :editing_cycle, cycle)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_edit_amount", _params, socket) do
|
||||
{:noreply, assign(socket, :editing_cycle, nil)}
|
||||
end
|
||||
|
||||
def handle_event("save_cycle_amount", %{"cycle_id" => cycle_id, "amount" => amount_str}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Normalize comma to dot for decimal parsing (German locale support)
|
||||
normalized_amount_str = String.replace(amount_str, ",", ".")
|
||||
|
||||
case Decimal.parse(normalized_amount_str) do
|
||||
{amount, _} when is_struct(amount, Decimal) ->
|
||||
case cycle
|
||||
|> Ash.Changeset.for_update(:update, %{amount: amount})
|
||||
|> Ash.update(domain: MembershipFees) do
|
||||
{:ok, updated_cycle} ->
|
||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:editing_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle amount updated"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Invalid amount format"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Load cycle with membership_fee_type for display
|
||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
||||
|
||||
{:noreply, assign(socket, :deleting_cycle, cycle)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_delete_cycle", _params, socket) do
|
||||
{:noreply, assign(socket, :deleting_cycle, nil)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
case Ash.destroy(cycle, domain: MembershipFees) do
|
||||
:ok ->
|
||||
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
||||
|
||||
{:ok, _destroyed} ->
|
||||
# Handle case where return_destroyed? is true
|
||||
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_all_cycles", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, true)
|
||||
|> assign(:delete_all_confirmation, "")}
|
||||
end
|
||||
|
||||
def handle_event("cancel_delete_all_cycles", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")}
|
||||
end
|
||||
|
||||
def handle_event("update_delete_all_confirmation", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, :delete_all_confirmation, value)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_delete_all_cycles", _params, socket) do
|
||||
# Validate confirmation (case-insensitive, trimmed)
|
||||
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
|
||||
expected = String.downcase(gettext("Yes"))
|
||||
|
||||
if confirmation != expected do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:error, gettext("Confirmation text does not match"))}
|
||||
else
|
||||
member = socket.assigns.member
|
||||
|
||||
# Delete all cycles atomically using Ecto query
|
||||
import Ecto.Query
|
||||
|
||||
deleted_count =
|
||||
Mv.Repo.delete_all(
|
||||
from c in Mv.MembershipFees.MembershipFeeCycle,
|
||||
where: c.member_id == ^member.id
|
||||
)
|
||||
|
||||
if deleted_count > 0 do
|
||||
# Reload member to get updated cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
updated_cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:info, gettext("No cycles to delete"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("open_create_cycle_modal", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:creating_cycle, true)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_create_cycle", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
||||
date =
|
||||
case Date.from_iso8601(date_str) do
|
||||
{:ok, date} -> date
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_date, date)
|
||||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("create_cycle", %{"date" => date_str, "amount" => amount_str}, socket) do
|
||||
member = socket.assigns.member
|
||||
|
||||
# Normalize comma to dot for decimal parsing (German locale support)
|
||||
normalized_amount_str = String.replace(amount_str, ",", ".")
|
||||
|
||||
amount =
|
||||
case Decimal.parse(normalized_amount_str) do
|
||||
{d, _} when is_struct(d, Decimal) -> {:ok, d}
|
||||
:error -> {:error, :invalid_amount}
|
||||
end
|
||||
|
||||
with {:ok, date} <- Date.from_iso8601(date_str),
|
||||
{:ok, amount} <- amount,
|
||||
cycle_start <-
|
||||
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
|
||||
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
|
||||
attrs = %{
|
||||
cycle_start: cycle_start,
|
||||
amount: amount,
|
||||
status: :unpaid,
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: member.membership_fee_type_id
|
||||
}
|
||||
|
||||
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
|
||||
{:ok, _new_cycle} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)
|
||||
|> put_flash(:info, gettext("Cycle created successfully"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, format_error(error))}
|
||||
end
|
||||
else
|
||||
:error ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, gettext("Invalid date format"))}
|
||||
|
||||
{:error, :invalid_amount} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, gettext("Invalid amount format"))}
|
||||
|
||||
{:error, :cycle_exists} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:create_cycle_error,
|
||||
gettext("A cycle for this period already exists")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp get_available_fee_types(member) do
|
||||
all_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|
||||
# If member has a fee type, filter to same interval
|
||||
if member.membership_fee_type do
|
||||
Enum.filter(all_types, fn type ->
|
||||
type.interval == member.membership_fee_type.interval
|
||||
end)
|
||||
else
|
||||
all_types
|
||||
end
|
||||
end
|
||||
|
||||
defp update_member_fee_type(member, fee_type_id) do
|
||||
attrs = %{membership_fee_type_id: fee_type_id}
|
||||
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|
||||
|> Ash.update(domain: Membership)
|
||||
end
|
||||
|
||||
defp find_cycle(cycles, cycle_id) do
|
||||
case Enum.find(cycles, &(&1.id == cycle_id)) do
|
||||
nil -> raise "Cycle not found: #{cycle_id}"
|
||||
cycle -> cycle
|
||||
end
|
||||
end
|
||||
|
||||
defp replace_cycle(cycles, updated_cycle) do
|
||||
Enum.map(cycles, fn cycle ->
|
||||
if cycle.id == updated_cycle.id, do: updated_cycle, else: cycle
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
defp format_status_label(:suspended), do: gettext("Suspended")
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
defp validate_cycle_not_exists(cycles, cycle_start) do
|
||||
if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do
|
||||
{:error, :cycle_exists}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp format_create_cycle_period(date, interval) when is_struct(date, Date) do
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(date, interval)
|
||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
MembershipFeeHelpers.format_cycle_range(cycle_start, interval) <>
|
||||
" (#{Calendar.strftime(cycle_start, "%d.%m.%Y")} - #{Calendar.strftime(cycle_end, "%d.%m.%Y")})"
|
||||
end
|
||||
|
||||
defp format_create_cycle_period(_date, _interval), do: ""
|
||||
|
||||
# Helper component for section box
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp section_box(assigns) do
|
||||
~H"""
|
||||
<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
|
||||
296
lib/mv_web/live/membership_fee_settings_live.ex
Normal file
296
lib/mv_web/live/membership_fee_settings_live.ex
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
defmodule MvWeb.MembershipFeeSettingsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing membership fee settings (Admin).
|
||||
|
||||
Allows administrators to configure:
|
||||
- Default membership fee type for new members
|
||||
- Whether to include the joining cycle in membership fee generation
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
membership_fee_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:membership_fee_types, membership_fee_types)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@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))}
|
||||
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
|
||||
{:ok, updated_settings} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings, updated_settings)
|
||||
|> put_flash(:info, gettext("Settings saved successfully."))
|
||||
|> assign_form()}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Membership Fee Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings for membership fees.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<%!-- Settings Form --%>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" />
|
||||
{gettext("Global Settings")}
|
||||
</h2>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class="space-y-6"
|
||||
>
|
||||
<%!-- Default Membership Fee Type --%>
|
||||
<fieldset class="fieldset">
|
||||
<label for="default_membership_fee_type_id" class="label">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Default Membership Fee Type")}
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
id="default_membership_fee_type_id"
|
||||
name="settings[default_membership_fee_type_id]"
|
||||
class={[
|
||||
"select select-bordered w-full",
|
||||
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
||||
]}
|
||||
phx-debounce="blur"
|
||||
aria-label={gettext("Default Membership Fee Type")}
|
||||
>
|
||||
<option value="">{gettext("None (no default)")}</option>
|
||||
<option
|
||||
:for={fee_type <- @membership_fee_types}
|
||||
value={fee_type.id}
|
||||
selected={fee_type.id == @form[:default_membership_fee_type_id].value}
|
||||
>
|
||||
{fee_type.name} ({format_currency(fee_type.amount)}, {format_interval(
|
||||
fee_type.interval
|
||||
)})
|
||||
</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, []} %>
|
||||
<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."
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<%!-- Include Joining Cycle --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="settings[include_joining_cycle]"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={@form[:include_joining_cycle].value}
|
||||
phx-debounce="blur"
|
||||
/>
|
||||
<span class="label-text font-semibold">
|
||||
{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, []} %>
|
||||
<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.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When inactive: Members pay from the next full cycle after joining.")}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
<.icon name="hero-check" class="size-5" />
|
||||
{gettext("Save Settings")}
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Examples Card --%>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</h2>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example section component
|
||||
attr :title, :string, required: true
|
||||
attr :joining_date, :string, required: true
|
||||
attr :include_joining, :boolean, required: true
|
||||
attr :start_date, :string, required: true
|
||||
attr :periods, :list, required: true
|
||||
attr :note, :string, required: true
|
||||
|
||||
defp example_section(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold text-sm">{@title}</h3>
|
||||
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
||||
<p>
|
||||
<span class="text-base-content/80">{gettext("Joining date")}:</span>
|
||||
<span class="font-mono">{@joining_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/80">{gettext("Membership fee start")}:</span>
|
||||
<span class="font-mono font-semibold text-base-content">{@start_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/80">{gettext("Generated cycles")}:</span>
|
||||
<span class="font-mono">
|
||||
{Enum.join(@periods, ", ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/80 italic">→ {@note}</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_currency(%Decimal{} = amount) do
|
||||
"#{Decimal.to_string(amount)} €"
|
||||
end
|
||||
|
||||
defp format_interval(:monthly), do: gettext("Monthly")
|
||||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
settings,
|
||||
:update_membership_fee_settings,
|
||||
api: Membership,
|
||||
as: "settings",
|
||||
forms: [auto?: true]
|
||||
)
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
end
|
||||
455
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
455
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing membership fee types (Admin).
|
||||
|
||||
## Features
|
||||
- Create new membership fee types
|
||||
- Edit existing membership fee types (name, amount, description - NOT interval)
|
||||
- Amount change warning modal (shows impact on members)
|
||||
- Interval field grayed out on edit
|
||||
|
||||
## Permissions
|
||||
- Admin only
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<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 ->
|
||||
hide_amount_warning(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# Shows amount change warning with affected member count
|
||||
# Only calculates count if warning is being shown for the first time (false -> true)
|
||||
defp show_amount_warning(socket, old_amount, new_amount, new_amount_str) do
|
||||
# Only calculate count if warning is not already shown (optimization)
|
||||
affected_count =
|
||||
if socket.assigns.show_amount_warning do
|
||||
# Warning already shown, reuse existing count
|
||||
socket.assigns.affected_member_count
|
||||
else
|
||||
# Warning being shown for first time, calculate count
|
||||
get_affected_member_count(socket.assigns.membership_fee_type.id)
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:show_amount_warning, true)
|
||||
|> assign(:old_amount, old_amount)
|
||||
|> assign(:new_amount, new_amount)
|
||||
|> assign(:affected_member_count, affected_count)
|
||||
|> assign(:pending_amount, new_amount_str)
|
||||
end
|
||||
|
||||
# Hides amount change warning
|
||||
defp hide_amount_warning(socket) do
|
||||
socket
|
||||
|> assign(:show_amount_warning, false)
|
||||
|> assign(:pending_amount, nil)
|
||||
end
|
||||
|
||||
defp get_affected_member_count(fee_type_id) do
|
||||
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
|
||||
{:ok, count} -> count
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
end
|
||||
224
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
224
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for managing membership fee types (Admin).
|
||||
|
||||
## Features
|
||||
- List all membership fee types
|
||||
- Display: Name, Amount, Interval, Member count
|
||||
- Create new membership fee types
|
||||
- Edit existing membership fee types (name, amount, description - NOT interval)
|
||||
- Delete membership fee types (if no members assigned)
|
||||
|
||||
## Permissions
|
||||
- Admin only
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
fee_types = load_membership_fee_types()
|
||||
member_counts = load_member_counts(fee_types)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Types"))
|
||||
|> assign(:membership_fee_types, fee_types)
|
||||
|> assign(:member_counts, member_counts)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<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, domain: MembershipFees)
|
||||
|
||||
case Ash.destroy(fee_type, domain: MembershipFees) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp load_membership_fee_types do
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: MembershipFees)
|
||||
end
|
||||
|
||||
# Loads all member counts for fee types in a single query to avoid N+1 queries
|
||||
defp load_member_counts(fee_types) do
|
||||
fee_type_ids = Enum.map(fee_types, & &1.id)
|
||||
|
||||
# Load all members with membership_fee_type_id in a single query
|
||||
members =
|
||||
Member
|
||||
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
||||
|> Ash.Query.select([:membership_fee_type_id])
|
||||
|> Ash.read!(domain: Membership)
|
||||
|
||||
# Group by membership_fee_type_id and count
|
||||
members
|
||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
# Gets member count from preloaded assigns map
|
||||
defp get_member_count(fee_type, member_counts) do
|
||||
Map.get(member_counts, fee_type.id, 0)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
# Info card explaining the membership fee type concept
|
||||
defp info_card(assigns) do
|
||||
~H"""
|
||||
<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
|
||||
181
lib/mv_web/member_live/index/membership_fee_status.ex
Normal file
181
lib/mv_web/member_live/index/membership_fee_status.ex
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||
@moduledoc """
|
||||
Helper module for membership fee status display in member list view.
|
||||
|
||||
Provides functions to efficiently load and determine cycle status for members
|
||||
in the list view, avoiding N+1 queries.
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@doc """
|
||||
Loads membership fee cycles for members efficiently.
|
||||
|
||||
Preloads cycles with membership_fee_type relationship to avoid N+1 queries.
|
||||
Note: This loads all cycles for each member. The filtering to get the relevant
|
||||
cycle (current or last completed) happens in `get_cycle_status_for_member/2`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `query` - Ash query for members
|
||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
|
||||
- `today` - Optional date to use as reference (currently unused, kept for API compatibility)
|
||||
|
||||
## Returns
|
||||
|
||||
Modified query with cycles loaded
|
||||
|
||||
## Performance
|
||||
|
||||
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
||||
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
||||
"""
|
||||
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
|
||||
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
|
||||
# Load membership_fee_type and cycles
|
||||
query
|
||||
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the cycle status for a member.
|
||||
|
||||
Returns the status of either the last completed cycle or the current cycle,
|
||||
depending on the `show_current` parameter.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - Member struct with loaded cycles and membership_fee_type
|
||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status
|
||||
|
||||
## Returns
|
||||
|
||||
- `:paid`, `:unpaid`, or `:suspended` if cycle exists
|
||||
- `nil` if no cycle exists
|
||||
|
||||
## Examples
|
||||
|
||||
# Get last completed cycle status
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
:paid
|
||||
|
||||
# Get current cycle status
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true)
|
||||
:unpaid
|
||||
"""
|
||||
@spec get_cycle_status_for_member(Member.t(), boolean(), Date.t() | nil) ::
|
||||
:paid | :unpaid | :suspended | nil
|
||||
def get_cycle_status_for_member(member, show_current \\ false, today \\ nil) do
|
||||
cycle =
|
||||
if show_current do
|
||||
MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
else
|
||||
MembershipFeeHelpers.get_last_completed_cycle(member, today)
|
||||
end
|
||||
|
||||
case cycle do
|
||||
nil -> nil
|
||||
cycle -> cycle.status
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats cycle status as a badge component.
|
||||
|
||||
Returns a map with badge information for rendering in templates.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`)
|
||||
|
||||
## Returns
|
||||
|
||||
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
|
||||
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||
nil
|
||||
"""
|
||||
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
|
||||
%{color: String.t(), icon: String.t(), label: String.t()} | nil
|
||||
def format_cycle_status_badge(nil), do: nil
|
||||
|
||||
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
||||
%{
|
||||
color: MembershipFeeHelpers.status_color(status),
|
||||
icon: MembershipFeeHelpers.status_icon(status),
|
||||
label: format_status_label(status)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members by cycle status (paid or unpaid).
|
||||
|
||||
Returns members that have the specified status in either the last completed cycle
|
||||
or the current cycle, depending on `show_current`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `members` - List of member structs with loaded cycles
|
||||
- `status` - Cycle status to filter by (`:paid` or `:unpaid`)
|
||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
||||
|
||||
## Returns
|
||||
|
||||
List of members with the specified cycle status
|
||||
|
||||
## Examples
|
||||
|
||||
# Filter unpaid members in last cycle
|
||||
iex> filter_members_by_cycle_status(members, :unpaid, false)
|
||||
[%Member{}, ...]
|
||||
|
||||
# Filter paid members in current cycle
|
||||
iex> filter_members_by_cycle_status(members, :paid, true)
|
||||
[%Member{}, ...]
|
||||
"""
|
||||
@spec filter_members_by_cycle_status([Member.t()], :paid | :unpaid, boolean()) :: [Member.t()]
|
||||
def filter_members_by_cycle_status(members, status, show_current \\ false)
|
||||
when status in [:paid, :unpaid] do
|
||||
Enum.filter(members, fn member ->
|
||||
member_status = get_cycle_status_for_member(member, show_current)
|
||||
member_status == status
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members by unpaid cycle status.
|
||||
|
||||
Returns members that have unpaid cycles in either the last completed cycle
|
||||
or the current cycle, depending on `show_current`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `members` - List of member structs with loaded cycles
|
||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
||||
|
||||
## Returns
|
||||
|
||||
List of members with unpaid cycles
|
||||
|
||||
## Deprecated
|
||||
|
||||
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
|
||||
"""
|
||||
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
|
||||
def filter_unpaid_members(members, show_current \\ false) do
|
||||
filter_members_by_cycle_status(members, :unpaid, show_current)
|
||||
end
|
||||
|
||||
# Private helper function to format status label
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
defp format_status_label(:suspended), do: gettext("Suspended")
|
||||
end
|
||||
|
|
@ -69,9 +69,16 @@ defmodule MvWeb.Router do
|
|||
|
||||
live "/settings", GlobalSettingsLive
|
||||
|
||||
# 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 "/contribution_settings", ContributionSettingsLive
|
||||
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
|
||||
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:first_name), do: gettext("First Name")
|
||||
def label(:last_name), do: gettext("Last Name")
|
||||
def label(:email), do: gettext("Email")
|
||||
def label(:paid), do: gettext("Paid")
|
||||
def label(:phone_number), do: gettext("Phone")
|
||||
def label(:join_date), do: gettext("Join Date")
|
||||
def label(:exit_date), do: gettext("Exit Date")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue