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:
Simon 2026-07-10 16:27:15 +02:00
parent 7d1a71b1fd
commit c547ca19af
35 changed files with 2817 additions and 3149 deletions

View file

@ -100,7 +100,14 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
`exit_date` slice is touched; `join_date` and custom-date entries are left
intact, so the quick filter and the detailed control share one source.
"""
@spec set_quick_state(map(), :active | :former | :all) :: map()
@spec set_quick_state(map(), :active | :former | :all) :: %{
:exit_date => %{
from: nil,
mode: :active_only | :inactive_only | :all,
to: nil
},
optional(any()) => any()
}
def set_quick_state(filters, state)
when is_map(filters) and state in [:active, :former, :all] do
Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil})

View file

@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
query: assigns[:query] || nil,
sort_field: sort_field(assigns[:sort_field]),
sort_order: sort_order(assigns[:sort_order]),
show_current_cycle: assigns[:show_current_cycle] || false,
cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
# Decoupled from the removed overview current/last cycle toggle (§3.3): the
# export's fee-status column always uses the current cycle as its basis and
# applies no cycle-status row filter (the overview now filters payment via
# the period-scoped aging model, which the export does not mirror).
show_current_cycle: true,
cycle_status_filter: nil,
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
}
end
@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
def sort_order(:desc), do: "desc"
def sort_order(o) when is_binary(o), do: o
defp cycle_status_filter(nil), do: nil
defp cycle_status_filter(:paid), do: "paid"
defp cycle_status_filter(:unpaid), do: "unpaid"
defp cycle_status_filter(_), do: nil
defp build_member_fields_list(ordered_db, member_fields_visible) do
member_fields_visible = member_fields_visible || []

View file

@ -65,7 +65,7 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do
def group_for(%{group: group}), do: group
@doc "The fixed display order of the picker groups."
@spec group_order() :: [group()]
@spec group_order() :: [group(), ...]
def group_order, do: @group_order
@doc """

View file

@ -47,17 +47,10 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
)
|> apply_date_filters(opts[:date_filters])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|> apply_cycle_status_filter(
opts[:cycle_status_filter],
opts[:show_current_cycle],
today(opts)
)
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
end
defp today(opts), do: opts[:today] || Date.utc_today()
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@ -184,55 +177,6 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
# ---------------------------------------------------------------------------
# Cycle-status filter (paid/unpaid, current or last-completed cycle)
#
# Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
# matches only when its selected-cycle status equals the requested status;
# members with no matching cycle (nil aggregate) are excluded — exactly as the
# previous in-memory classifier behaved.
# ---------------------------------------------------------------------------
defp apply_cycle_status_filter(query, status, _show_current, _today)
when status not in [:paid, :unpaid],
do: query
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
# Current cycle: contains today; when several would, the one with the latest
# cycle_start wins. Keep members whose winning current cycle has `status`.
Ash.Query.filter(
query,
expr(
exists(
membership_fee_cycles,
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
not exists(
member.membership_fee_cycles,
cycle_start <= ^today and cycle_end >= ^today and
cycle_start > parent(cycle_start)
)
)
)
)
end
defp apply_cycle_status_filter(query, status, _show_current, today) do
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
Ash.Query.filter(
query,
expr(
exists(
membership_fee_cycles,
cycle_end < ^today and status == ^status and
not exists(
member.membership_fee_cycles,
cycle_end < ^today and cycle_start > parent(cycle_start)
)
)
)
)
end
# ---------------------------------------------------------------------------
# Payment aging filter (period-scoped unpaid-cycle count)
#

View file

@ -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.yyyydd.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