From 09dfbe455b0d63fc1dccfe4c37f4f7ac9a6fdd20 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 16 Dec 2025 10:57:44 +0100 Subject: [PATCH] feat: add membership fee helper modules MembershipFeeHelpers: formatting functions for currency, intervals, cycles MembershipFeeStatus: helper for loading and determining cycle status in member list --- lib/mv_web/helpers/membership_fee_helpers.ex | 203 ++++++++++++++++++ .../index/membership_fee_status.ex | 143 ++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 lib/mv_web/helpers/membership_fee_helpers.ex create mode 100644 lib/mv_web/member_live/index/membership_fee_status.ex diff --git a/lib/mv_web/helpers/membership_fee_helpers.ex b/lib/mv_web/helpers/membership_fee_helpers.ex new file mode 100644 index 0000000..ea866e4 --- /dev/null +++ b/lib/mv_web/helpers/membership_fee_helpers.ex @@ -0,0 +1,203 @@ +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 + amount_str = Decimal.to_string(amount, :normal) + amount_str = String.replace(amount_str, ".", ",") + "#{amount_str} €" + 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 || [] + + cycles + |> Enum.filter(fn cycle -> + CalendarCycles.last_completed_cycle?(cycle.cycle_start, fee_type.interval, today) + end) + |> List.first() + end + end + + @doc """ + Gets the current cycle for a member. + + Returns the cycle that contains today's date. + Returns `nil` if no current cycle exists. + + ## Parameters + + - `member` - Member struct with loaded membership_fee_cycles and membership_fee_type + - `today` - Optional date to use as reference (defaults to today) + + ## Returns + + - `%MembershipFeeCycle{}` if found + - `nil` if no current cycle exists + + ## Examples + + # Member with cycles, today is 2024-06-15 (within Q2 2024) + iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member) + # => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...} + """ + @spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil + def get_current_cycle(member, today \\ nil) + + def get_current_cycle(%Member{} = member, today) do + today = today || Date.utc_today() + + case member.membership_fee_type do + nil -> + nil + + fee_type -> + cycles = member.membership_fee_cycles || [] + + cycles + |> Enum.filter(fn cycle -> + CalendarCycles.current_cycle?(cycle.cycle_start, fee_type.interval, today) + end) + |> List.first() + end + end + + @doc """ + Gets the CSS color class for a status badge. + + ## Examples + + iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:paid) + "badge-success" + + iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:unpaid) + "badge-error" + + iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:suspended) + "badge-ghost" + """ + @spec status_color(:paid | :unpaid | :suspended) :: String.t() + def status_color(:paid), do: "badge-success" + def status_color(:unpaid), do: "badge-error" + def status_color(:suspended), do: "badge-ghost" + + @doc """ + Gets the icon name for a status. + + ## Examples + + iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:paid) + "hero-check-circle" + + iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:unpaid) + "hero-x-circle" + + iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:suspended) + "hero-pause-circle" + """ + @spec status_icon(:paid | :unpaid | :suspended) :: String.t() + def status_icon(:paid), do: "hero-check-circle" + def status_icon(:unpaid), do: "hero-x-circle" + def status_icon(:suspended), do: "hero-pause-circle" + + # Private helper function for date formatting + defp format_date(%Date{} = date) do + Calendar.strftime(date, "%d.%m.%Y") + end +end diff --git a/lib/mv_web/member_live/index/membership_fee_status.ex b/lib/mv_web/member_live/index/membership_fee_status.ex new file mode 100644 index 0000000..4b31ebb --- /dev/null +++ b/lib/mv_web/member_live/index/membership_fee_status.ex @@ -0,0 +1,143 @@ +defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do + @moduledoc """ + Helper module for membership fee status display in member list view. + + Provides functions to efficiently load and determine cycle status for members + in the list view, avoiding N+1 queries. + """ + + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership.Member + alias MvWeb.Helpers.MembershipFeeHelpers + + @doc """ + Loads membership fee cycles for members efficiently. + + Preloads cycles with membership_fee_type relationship to avoid N+1 queries. + Only loads the relevant cycle per member (last completed or current, depending on show_current). + + ## Parameters + + - `query` - Ash query for members + - `show_current` - If true, load current cycle; if false, load last completed cycle + - `today` - Optional date to use as reference (defaults to today) + + ## Returns + + Modified query with cycles loaded + + ## Performance + + Uses Ash.Query.load to efficiently preload cycles in a single query. + Filters cycles at database level to only load the relevant cycle per member. + """ + @spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t() + def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do + # Load membership_fee_type and cycles with efficient filtering + query + |> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]]) + end + + @doc """ + Gets the cycle status for a member. + + Returns the status of either the last completed cycle or the current cycle, + depending on the `show_current` parameter. + + ## Parameters + + - `member` - Member struct with loaded cycles and membership_fee_type + - `show_current` - If true, get current cycle status; if false, get last completed cycle status + + ## Returns + + - `:paid`, `:unpaid`, or `:suspended` if cycle exists + - `nil` if no cycle exists + + ## Examples + + # Get last completed cycle status + iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false) + :paid + + # Get current cycle status + iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true) + :unpaid + """ + @spec get_cycle_status_for_member(Member.t(), boolean()) :: :paid | :unpaid | :suspended | nil + def get_cycle_status_for_member(member, show_current \\ false) do + cycle = + if show_current do + MembershipFeeHelpers.get_current_cycle(member) + else + MembershipFeeHelpers.get_last_completed_cycle(member) + end + + case cycle do + nil -> nil + cycle -> cycle.status + end + end + + @doc """ + Formats cycle status as a badge component. + + Returns a map with badge information for rendering in templates. + + ## Parameters + + - `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`) + + ## Returns + + Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil + + ## Examples + + iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid) + %{color: "badge-success", icon: "hero-check-circle", label: "Paid"} + + iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil) + nil + """ + @spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) :: + %{color: String.t(), icon: String.t(), label: String.t()} | nil + def format_cycle_status_badge(nil), do: nil + + def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do + %{ + color: MembershipFeeHelpers.status_color(status), + icon: MembershipFeeHelpers.status_icon(status), + label: format_status_label(status) + } + end + + @doc """ + Filters members by 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 + """ + @spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()] + def filter_unpaid_members(members, show_current \\ false) do + Enum.filter(members, fn member -> + status = get_cycle_status_for_member(member, show_current) + status == :unpaid + end) + 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