diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index af0214e7..62a82026 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -49,6 +49,7 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
+ alias MvWeb.Helpers.MembershipFeeHelpers
alias MvWeb.MemberLive.Index.AsOfDate
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 0a5b9280..39182330 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -511,8 +511,9 @@
{badge.label}
<%= if count > 0 do %>
- <% cycles = PaymentAging.open_cycles(member, @payment_period) %>
- <% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
+ <% %{unpaid: unpaid, suspended: suspended} =
+ PaymentAging.partition_open_cycles(member, @payment_period) %>
+ <% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %>
-
+
-
-
- <.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" />
+ <.badge
+ variant={MembershipFeeHelpers.status_variant(cycle.status)}
+ size="sm"
+ class="w-full justify-start"
+ >
+ <:icon>
+ <.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-3.5" />
+
{PaymentAging.short_period(cycle)}
-
+
- 0} class="flex">
@@ -538,6 +545,28 @@
+ <%!-- Suspended cycles are shown separately under their own label so
+ the status distinction is not color-only (§1.14). --%>
+
+
{gettext("Suspended")}
+
+ -
+ <.badge
+ variant={MembershipFeeHelpers.status_variant(cycle.status)}
+ size="sm"
+ class="w-full justify-start"
+ >
+ <:icon>
+ <.icon
+ name={MembershipFeeHelpers.status_icon(cycle.status)}
+ class="size-3.5"
+ />
+
+ {PaymentAging.short_period(cycle)}
+
+
+
+
<% end %>
diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex
index 644a1597..4f48c326 100644
--- a/lib/mv_web/live/member_live/index/payment_aging.ex
+++ b/lib/mv_web/live/member_live/index/payment_aging.ex
@@ -272,14 +272,32 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|> Enum.sort_by(& &1.cycle_start, Date)
end
+ @doc """
+ Partitions a member's open cycles for the period into the unpaid cycles (which
+ drive the count and the badge) and the suspended cycles, which the tooltip
+ surfaces in their own labeled section rather than by color alone (§1.14).
+ Both lists stay in chronological order.
+ """
+ @spec partition_open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]}
+ def partition_open_cycles(member, period) do
+ {unpaid, suspended} =
+ member
+ |> open_cycles(period)
+ |> Enum.split_with(&(&1.status == :unpaid))
+
+ %{unpaid: unpaid, suspended: suspended}
+ end
+
@tooltip_slots 5
@doc """
- Partitions open cycles into the tooltip's fixed five-slot budget: at most
- five badges total. Five or fewer cycles show in full with no overflow. More
- than five collapse to the first four chronological cycles plus a single
- overflow count (total − 4), so the tooltip renders four cycle badges and one
- grey overflow badge — five items.
+ Partitions the tooltip's unpaid sub-list into a fixed five-slot budget: at
+ most five badges for the unpaid cycles. Five or fewer unpaid cycles show in
+ full with no overflow. More than five collapse to the first four chronological
+ cycles plus a single overflow count (total − 4), so the unpaid sub-list
+ renders four cycle badges and one grey overflow badge — five items. Suspended
+ cycles are listed separately (in their own labeled section) and are not capped
+ by this budget, so the tooltip's overall badge count can exceed five.
"""
@spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()}
def tooltip_cycles(cycles) when is_list(cycles) do
@@ -290,22 +308,6 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
end
end
- @doc """
- Static daisyUI badge color token for a cycle status, matching the codes used
- on the member show page. Literal strings so Tailwind's content scanner keeps
- them in the bundle (a runtime `badge-\#{status}` would be tree-shaken).
- """
- @spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t()
- def cycle_color_class(:paid), do: "badge-success"
- def cycle_color_class(:unpaid), do: "badge-error"
- def cycle_color_class(:suspended), do: "badge-warning"
-
- @doc """
- Hero-icon name for a cycle status (reuses the established status icons).
- """
- @spec cycle_icon(:paid | :unpaid | :suspended) :: String.t()
- def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status)
-
@doc """
Short, human-readable period label for a cycle, derived from its `cycle_start`
and the fee-type interval:
@@ -313,7 +315,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
* yearly → `"2025"`
* quarterly → `"Q1 2026"`
* half-yearly → `"H1 2026"`
- * monthly → `"März 2026"`
+ * monthly → `"March 2026"`
Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not
loaded/known.
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 1a8682ad..5706d57b 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3259,6 +3259,7 @@ msgid "Summary"
msgstr "Zusammenfassung"
#: lib/mv/membership/members_pdf.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index ae7e899b..309f5ae4 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3260,6 +3260,7 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 9f43b72f..a8decc87 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3260,6 +3260,7 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs
index ae6e5657..f4a2afdc 100644
--- a/test/mv_web/member_live/index_membership_fee_status_test.exs
+++ b/test/mv_web/member_live/index_membership_fee_status_test.exs
@@ -83,6 +83,31 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
assert has_element?(view, "#payment-tip-#{member.id}[popover]")
end
+ test "the tooltip lists suspended cycles in a separate labeled section (§1.14)", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{first_name: "Mixed", membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
+
+ {:ok, view, _html} = live(conn, "/members")
+
+ tip = "#payment-tip-#{member.id}"
+
+ # Suspended cycles are shown separately, under their own text label — the
+ # distinction is not color-only. The suspended cycle's period lives in the
+ # suspended section and is absent from the unpaid list.
+ assert has_element?(view, "#{tip} [data-testid='payment-tooltip-suspended']", "Suspended")
+
+ suspended =
+ view |> element("#{tip} [data-testid='payment-tooltip-suspended']") |> render()
+
+ assert suspended =~ "2024"
+
+ unpaid = view |> element("#{tip} [data-testid='payment-tooltip-unpaid']") |> render()
+ assert unpaid =~ "2023"
+ refute unpaid =~ "2024"
+ end
+
test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id})