feat(overview): add point-in-time, suspended, OR-group and count-range filters
This commit is contained in:
parent
c547ca19af
commit
c0d1550401
23 changed files with 1116 additions and 117 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue