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.
This commit is contained in:
parent
7d1a71b1fd
commit
c547ca19af
35 changed files with 2817 additions and 3149 deletions
|
|
@ -24,6 +24,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
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}
|
||||
|
|
@ -99,40 +100,111 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
do: parse_filter(Map.get(params, @payment_filter_param))
|
||||
|
||||
@doc """
|
||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
|
||||
(success); a positive count reads "N unpaid" (error).
|
||||
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, label: gettext("Paid"), count: 0}
|
||||
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),
|
||||
label: gettext("%{count} unpaid", count: count),
|
||||
color_class: cycle_color_class(:unpaid),
|
||||
icon: MembershipFeeHelpers.status_icon(:unpaid),
|
||||
label: gettext("%{count} open", count: count),
|
||||
count: count
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Splits a member's loaded cycles into the open (unpaid) and suspended cycles
|
||||
whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid
|
||||
cycles and cycles outside the period are dropped. Used to render the badge
|
||||
tooltip. Expects `membership_fee_cycles` to be loaded on the member.
|
||||
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()) :: %{unpaid: [map()], suspended: [map()]}
|
||||
@spec open_cycles(map(), period()) :: [map()]
|
||||
def open_cycles(member, %{from: from, to: to}) do
|
||||
cycles = in_period_cycles(member, from, to)
|
||||
|
||||
%{
|
||||
unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(),
|
||||
suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end()
|
||||
}
|
||||
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))
|
||||
|
|
@ -147,7 +219,35 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
|
||||
defp in_period?(_, _, _), do: false
|
||||
|
||||
defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date)
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue