feat(overview): add point-in-time, suspended, OR-group and count-range filters

This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c547ca19af
commit c0d1550401
23 changed files with 1116 additions and 117 deletions

View file

@ -29,11 +29,18 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
@type filter :: nil | :fully_paid | {:has_unpaid, 1..3}
@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).
@ -83,6 +90,10 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@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"}
@ -90,14 +101,61 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
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: parse_filter(Map.get(params, @payment_filter_param))
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"
@ -132,6 +190,53 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
}
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). 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("Fees across all outstanding cycles")
def period_tooltip(%{from: from, to: to}) do
gettext("Fees in period %{from}%{to}", from: period_bound(from), to: period_bound(to))
end
def period_tooltip(_), do: gettext("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