mitgliederverwaltung/lib/mv_web/live/member_live/index/payment_aging.ex
Simon c547ca19af feat(overview): replace filter panel with add-filter builder and aging column
Swaps the unbounded vertical filter panel for a Polaris-style add-filter
builder (searchable grouped field picker, type-aware value control, applied
chips) and colour-codes the fees column with each member's period-scoped
unpaid-cycle count.
2026-07-10 16:27:15 +02:00

267 lines
9.2 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MvWeb.MemberLive.Index.PaymentAging do
@moduledoc """
Period-scoped payment aging for the member overview.
The overview presents payment as an accounts-receivable aging count: per
member, the number of that member's cycles with `status == :unpaid` whose
denormalized `cycle_end` falls inside an active period. The period is a view
dimension that scopes the column and the payment filter together; its default
is **all outstanding** (both bounds nil → all time). Suspended cycles are
excluded from the count but surfaced separately in the badge tooltip.
This module owns:
* the period value shape and its default,
* URL param encode/decode for the period,
* the badge descriptor derived from a count,
* the open-cycles list (unpaid + suspended, member-relative) that backs the
badge tooltip.
The count itself is computed DB-side by the `unpaid_cycle_count` calculation
on `Mv.Membership.Member`; `open_cycles/2` works over already-loaded cycles.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.Constants
alias MvWeb.Helpers.DateFormatter
alias MvWeb.Helpers.MembershipFeeHelpers
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
@type filter :: nil | :fully_paid | {:has_unpaid, 1..3}
@payment_period_from_param Constants.payment_period_from_param()
@payment_period_to_param Constants.payment_period_to_param()
@payment_filter_param Constants.payment_filter_param()
@doc """
The default period: all outstanding cycles, all time (both bounds nil).
"""
@spec default_period() :: period()
def default_period, do: %{from: nil, to: nil}
@doc """
Decodes URL params into a period. Absent or malformed ISO-8601 bounds fall
back to nil, so no params yields the all-outstanding default.
"""
@spec parse_period(map()) :: period()
def parse_period(params) when is_map(params) do
%{
from: parse_date(Map.get(params, @payment_period_from_param)),
to: parse_date(Map.get(params, @payment_period_to_param))
}
end
@doc """
Encodes a period into URL params. A nil bound is omitted; the default period
encodes to the empty map (a fresh URL is the canonical default).
"""
@spec to_params(period()) :: %{optional(String.t()) => String.t()}
def to_params(%{from: from, to: to}) do
%{}
|> maybe_put_date(@payment_period_from_param, from)
|> maybe_put_date(@payment_period_to_param, to)
end
def to_params(_), do: %{}
@doc """
Decodes the payment-count filter param.
* `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period)
* `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}`
(at least N unpaid cycles in the period)
* anything else → `nil` (no payment-count filter)
"""
@spec parse_filter(term()) :: filter()
def parse_filter("fully_paid"), do: :fully_paid
def parse_filter("unpaid_1"), do: {:has_unpaid, 1}
def parse_filter("unpaid_2"), do: {:has_unpaid, 2}
def parse_filter("unpaid_3"), do: {:has_unpaid, 3}
def parse_filter(_), do: nil
@doc """
Encodes a payment-count filter into URL params. `nil` yields the empty map.
"""
@spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()}
def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"}
def filter_to_params({:has_unpaid, n}) when n in 1..3,
do: %{@payment_filter_param => "unpaid_#{n}"}
def filter_to_params(_), do: %{}
@doc """
Decodes the payment-count filter from a full params map.
"""
@spec parse_filter_params(map()) :: filter()
def parse_filter_params(params) when is_map(params),
do: parse_filter(Map.get(params, @payment_filter_param))
@doc """
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
(success/green); a positive count reads "N offen" (error/red). Reuses the
established cycle-status color and hero-icon codes so the overview badge reads
consistently with the member show page.
"""
@spec badge(non_neg_integer()) :: %{
variant: atom(),
color_class: String.t(),
icon: String.t(),
label: String.t(),
count: non_neg_integer()
}
def badge(0) do
%{
variant: :success,
color_class: cycle_color_class(:paid),
icon: MembershipFeeHelpers.status_icon(:paid),
label: gettext("All paid"),
count: 0
}
end
def badge(count) when is_integer(count) and count > 0 do
%{
variant: MembershipFeeHelpers.status_variant(:unpaid),
color_class: cycle_color_class(:unpaid),
icon: MembershipFeeHelpers.status_icon(:unpaid),
label: gettext("%{count} open", count: count),
count: count
}
end
@doc """
A member's open cycles for the badge tooltip: the unpaid and suspended cycles
whose `cycle_end` falls inside `period`, merged into a single list sorted
chronologically by `cycle_start`. Paid cycles and cycles outside the period
are dropped. Expects `membership_fee_cycles` to be loaded on the member.
"""
@spec open_cycles(map(), period()) :: [map()]
def open_cycles(member, %{from: from, to: to}) do
member
|> in_period_cycles(from, to)
|> Enum.filter(&(&1.status in [:unpaid, :suspended]))
|> Enum.sort_by(& &1.cycle_start, Date)
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.
"""
@spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()}
def tooltip_cycles(cycles) when is_list(cycles) do
if length(cycles) > @tooltip_slots do
{Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)}
else
{cycles, 0}
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:
* yearly → `"2025"`
* quarterly → `"Q1 2026"`
* half-yearly → `"H1 2026"`
* monthly → `"März 2026"`
Falls back to a `dd.MM.yyyydd.MM.yyyy` date range when the interval is not
loaded/known.
"""
@spec short_period(map()) :: String.t()
def short_period(%{cycle_start: %Date{} = start} = cycle) do
case cycle_interval(cycle) do
:yearly -> Integer.to_string(start.year)
:quarterly -> "Q#{quarter(start.month)} #{start.year}"
:half_yearly -> "H#{half(start.month)} #{start.year}"
:monthly -> "#{month_name(start.month)} #{start.year}"
_ -> cycle_date_range(cycle)
end
end
def short_period(cycle), do: cycle_date_range(cycle)
defp in_period_cycles(member, from, to) do
case Map.get(member, :membership_fee_cycles) do
cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to))
_ -> []
end
end
defp in_period?(%Date{} = cycle_end, from, to) do
(is_nil(from) or Date.compare(cycle_end, from) != :lt) and
(is_nil(to) or Date.compare(cycle_end, to) != :gt)
end
defp in_period?(_, _, _), do: false
defp cycle_interval(%{membership_fee_type: %{interval: interval}})
when interval in [:monthly, :quarterly, :half_yearly, :yearly],
do: interval
defp cycle_interval(_), do: nil
defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}),
do: "#{DateFormatter.format_date(s)}#{DateFormatter.format_date(e)}"
defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s)
defp cycle_date_range(_), do: ""
defp quarter(month), do: div(month - 1, 3) + 1
defp half(month) when month <= 6, do: 1
defp half(_month), do: 2
defp month_name(1), do: gettext("January")
defp month_name(2), do: gettext("February")
defp month_name(3), do: gettext("March")
defp month_name(4), do: gettext("April")
defp month_name(5), do: gettext("May")
defp month_name(6), do: gettext("June")
defp month_name(7), do: gettext("July")
defp month_name(8), do: gettext("August")
defp month_name(9), do: gettext("September")
defp month_name(10), do: gettext("October")
defp month_name(11), do: gettext("November")
defp month_name(12), do: gettext("December")
defp maybe_put_date(params, _key, nil), do: params
defp maybe_put_date(params, key, %Date{} = date),
do: Map.put(params, key, Date.to_iso8601(date))
defp parse_date(nil), do: nil
defp parse_date(value) when is_binary(value) do
case Date.from_iso8601(String.trim(value)) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_date(_), do: nil
end