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.
267 lines
9.2 KiB
Elixir
267 lines
9.2 KiB
Elixir
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.yyyy–dd.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
|