Rework Filter using a filter builder #556

Open
simon wants to merge 11 commits from issue/mitgliederverwaltung-548 into issue/mitgliederverwaltung-547
7 changed files with 91 additions and 31 deletions
Showing only changes of commit 4d6294771a - Show all commits

View file

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

View file

@ -511,8 +511,9 @@
{badge.label}
</.link>
<%= 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) %>
<div
id={tip_id}
popover="manual"
@ -521,15 +522,21 @@
class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
data-testid="payment-tooltip"
>
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
<ul
class="m-0 flex list-none flex-col items-stretch gap-1 p-0"
data-testid="payment-tooltip-unpaid"
>
<li :for={cycle <- shown} class="flex">
<span class={[
"badge badge-soft badge-sm w-full justify-start",
PaymentAging.cycle_color_class(cycle.status)
]}>
<.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" />
</:icon>
{PaymentAging.short_period(cycle)}
</span>
</.badge>
</li>
<li :if={overflow > 0} class="flex">
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
@ -538,6 +545,28 @@
</span>
</li>
</ul>
<%!-- Suspended cycles are shown separately under their own label so
the status distinction is not color-only (§1.14). --%>
<div :if={suspended != []} class="mt-2" data-testid="payment-tooltip-suspended">
<p class="mb-1 text-xs font-medium opacity-70">{gettext("Suspended")}</p>
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
<li :for={cycle <- suspended} class="flex">
<.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"
/>
</:icon>
{PaymentAging.short_period(cycle)}
</.badge>
</li>
</ul>
</div>
</div>
<% end %>
</:col>

View file

@ -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.yyyydd.MM.yyyy` date range when the interval is not
loaded/known.

View file

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

View file

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

View file

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

View file

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