Editable filter chips, uniformly-sized value controls, working field-picker search, and native date-range inputs with relative presets in place of the vendored calendar — native inputs give year-jump, manual entry and keyboard accessibility for free.
386 lines
14 KiB
Elixir
386 lines
14 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}
|
||
| {:unpaid_range, pos_integer(), pos_integer() | nil}
|
||
|
||
@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()
|
||
@payment_count_min_param Constants.payment_count_min_param()
|
||
@payment_count_max_param Constants.payment_count_max_param()
|
||
@suspended_param Constants.suspended_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()) :: nil | :fully_paid | {:has_unpaid, 1..3}
|
||
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.
|
||
|
||
The two-sided `{:unpaid_range, min, max}` form encodes as
|
||
`pay_filter=has_unpaid` plus `pay_min` (and `pay_max` when bounded above),
|
||
so a fresh "has open" default (`min=1`, `max=nil`) round-trips canonically.
|
||
"""
|
||
@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({:unpaid_range, min, max}) when is_integer(min) and min > 0 do
|
||
%{@payment_filter_param => "has_unpaid", @payment_count_min_param => Integer.to_string(min)}
|
||
|> maybe_put_max(max)
|
||
end
|
||
|
||
def filter_to_params(_), do: %{}
|
||
|
||
defp maybe_put_max(params, max) when is_integer(max) and max > 0,
|
||
do: Map.put(params, @payment_count_max_param, Integer.to_string(max))
|
||
|
||
defp maybe_put_max(params, _max), do: params
|
||
|
||
@doc """
|
||
Decodes the payment-count filter from a full params map.
|
||
|
||
A `pay_filter=has_unpaid` value reads the `pay_min`/`pay_max` bounds into a
|
||
`{:unpaid_range, min, max}`; an absent/invalid `pay_min` defaults to 1 and an
|
||
absent/invalid `pay_max` to nil (unbounded). Other `pay_filter` values fall
|
||
back to the single-token decode (`fully_paid` / `unpaid_N`).
|
||
"""
|
||
@spec parse_filter_params(map()) :: filter()
|
||
def parse_filter_params(params) when is_map(params) do
|
||
case Map.get(params, @payment_filter_param) do
|
||
"has_unpaid" ->
|
||
{:unpaid_range, parse_count(Map.get(params, @payment_count_min_param), 1),
|
||
parse_count(Map.get(params, @payment_count_max_param), nil)}
|
||
|
||
other ->
|
||
parse_filter(other)
|
||
end
|
||
end
|
||
|
||
defp parse_count(value, default) when is_binary(value) do
|
||
case Integer.parse(String.trim(value)) do
|
||
{n, ""} when n > 0 -> n
|
||
_ -> default
|
||
end
|
||
end
|
||
|
||
defp parse_count(_value, default), do: default
|
||
|
||
@doc """
|
||
Decodes the suspended-status filter flag (§1.23) from a params map. The flag
|
||
is present (`suspended=1`) only when active; any other value reads as false.
|
||
"""
|
||
@spec parse_suspended(map()) :: boolean()
|
||
def parse_suspended(params) when is_map(params), do: Map.get(params, @suspended_param) == "1"
|
||
|
||
@doc """
|
||
Encodes the suspended-status filter flag into URL params. Only `true` emits a
|
||
param (`suspended=1`); false/nil yield the empty map so a fresh URL is canonical.
|
||
"""
|
||
@spec suspended_to_params(boolean() | nil) :: %{optional(String.t()) => String.t()}
|
||
def suspended_to_params(true), do: %{@suspended_param => "1"}
|
||
def suspended_to_params(_), do: %{}
|
||
|
||
@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 """
|
||
Compact short code for a payment period (§1.28), or nil when the period is not
|
||
compactly codeable (the header then falls back to a plain "filtered" badge).
|
||
|
||
* `{nil, nil}` (all-outstanding default) → nil (no badge)
|
||
* a full calendar year (Jan 1 – Dec 31) → `"2026"`
|
||
* a full calendar quarter → `"Q1 2026"`
|
||
* anything else (arbitrary or open-ended range) → nil
|
||
"""
|
||
@spec period_short_code(period()) :: String.t() | nil
|
||
def period_short_code(%{from: %Date{} = from, to: %Date{} = to}) do
|
||
cond do
|
||
full_year?(from, to) -> Integer.to_string(from.year)
|
||
full_quarter?(from, to) -> "Q#{quarter(from.month)} #{from.year}"
|
||
true -> nil
|
||
end
|
||
end
|
||
|
||
def period_short_code(_), do: nil
|
||
|
||
@doc """
|
||
Human-readable tooltip naming the active payment period with locally formatted
|
||
(`dd.MM.yyyy`) bounds (§1.28). Explains that the shown figures refer to the
|
||
filtered contribution period, then names it: the all-outstanding default
|
||
names all outstanding cycles; open-ended periods render an open bound as an
|
||
ellipsis.
|
||
"""
|
||
@spec period_tooltip(period()) :: String.t()
|
||
def period_tooltip(%{from: nil, to: nil}),
|
||
do:
|
||
gettext(
|
||
"The values refer to the filtered contribution period. Fees across all outstanding cycles"
|
||
)
|
||
|
||
def period_tooltip(%{from: from, to: to}) do
|
||
gettext(
|
||
"The values refer to the filtered contribution period. Fees in period %{from}–%{to}",
|
||
from: period_bound(from),
|
||
to: period_bound(to)
|
||
)
|
||
end
|
||
|
||
def period_tooltip(_),
|
||
do:
|
||
gettext(
|
||
"The values refer to the filtered contribution period. Fees across all outstanding cycles"
|
||
)
|
||
|
||
defp period_bound(%Date{} = d), do: DateFormatter.format_date(d)
|
||
defp period_bound(_), do: "…"
|
||
|
||
defp full_year?(from, to),
|
||
do:
|
||
from.month == 1 and from.day == 1 and to.year == from.year and to.month == 12 and
|
||
to.day == 31
|
||
|
||
defp full_quarter?(from, to) do
|
||
from.day == 1 and from.month in [1, 4, 7, 10] and to.year == from.year and
|
||
to == Date.end_of_month(Date.new!(from.year, from.month + 2, 1))
|
||
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
|