feat(overview): rework filter builder with editable chips and native date ranges

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.
This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c0d1550401
commit 8303a39041
23 changed files with 1905 additions and 565 deletions

File diff suppressed because it is too large Load diff

View file

@ -472,6 +472,8 @@ defmodule MvWeb.MemberLive.Index do
%{
payment_filter: nil,
payment_period: PaymentAging.default_period(),
suspended: false,
stichtag: nil,
group_filters: %{},
fee_type_filters: %{},
boolean_filters: %{},
@ -481,6 +483,46 @@ defmodule MvWeb.MemberLive.Index do
)
end
@impl true
def handle_info({:suspended_changed, suspended}, socket) do
socket =
socket
|> assign(:suspended, suspended)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{suspended: suspended}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_info({:stichtag_changed, stichtag}, socket) do
socket =
socket
|> assign(:stichtag, stichtag)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{stichtag: stichtag}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
updated_filters =
@ -595,6 +637,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> assign(:suspended, Map.get(opts, :suspended, false))
|> assign(:stichtag, Map.get(opts, :stichtag))
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()

View file

@ -63,6 +63,8 @@
date_filters={@date_filters}
payment_filter={@payment_filter}
payment_period={@payment_period}
suspended={@suspended}
stichtag={@stichtag}
/>
</div>
@ -471,7 +473,7 @@
/>
<span
:if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
class="badge badge-soft badge-info badge-sm shrink-0"
class="badge badge-soft badge-primary badge-sm font-normal shrink-0"
title={PaymentAging.period_tooltip(@payment_period)}
aria-label={PaymentAging.period_tooltip(@payment_period)}
data-testid="payment-period-badge"
@ -485,20 +487,28 @@
<% count = member.unpaid_cycle_count || 0 %>
<% badge = PaymentAging.badge(count) %>
<% tip_id = "payment-tip-#{member.id}" %>
<button
type="button"
class={["badge badge-soft badge-md cursor-pointer", badge.color_class]}
data-variant={badge.variant}
phx-hook={count > 0 && "SortTooltip"}
<% anchor = "--pay-#{member.id}" %>
<%!-- The badge is a real navigation link (semantic, keyboard- and
right-click-friendly) into the member's payment history. Its hover/focus
tooltip is a native Popover-API element in the top layer, placed via CSS
anchor positioning (see the `payment-tip` class + the HoverPopover hook),
so it escapes the members table's `overflow` clipping without any custom
coordinate math. No tooltip renders when the member is fully paid. --%>
<.link
navigate={~p"/members/#{member.id}?tab=membership_fees"}
id={"payment-badge-#{member.id}"}
data-tooltip-id={count > 0 && tip_id}
phx-click={JS.navigate(~p"/members/#{member.id}?tab=membership_fees")}
class={["badge badge-soft badge-md", badge.color_class]}
style={count > 0 && "anchor-name: #{anchor}"}
data-variant={badge.variant}
phx-hook={count > 0 && "HoverPopover"}
data-popover-target={count > 0 && tip_id}
aria-details={count > 0 && tip_id}
aria-label={gettext("%{label} — open payment history", label: badge.label)}
data-testid="payment-badge"
>
<.icon name={badge.icon} class="size-4" />
{badge.label}
</button>
</.link>
<%= if count > 0 do %>
<% cycles = PaymentAging.open_cycles(member, @payment_period) %>
<% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
@ -506,7 +516,8 @@
id={tip_id}
popover="manual"
role="tooltip"
class="m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
style={"position-anchor: #{anchor}"}
class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
data-testid="payment-tooltip"
>
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
@ -521,7 +532,8 @@
</li>
<li :if={overflow > 0} class="flex">
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
{gettext("+%{count} more", count: overflow)}
<.icon name="hero-plus-circle" class="size-3.5" />
{gettext("%{count} more", count: overflow)}
</span>
</li>
</ul>

View file

@ -39,7 +39,7 @@ defmodule MvWeb.MemberLive.Index.DatePresets do
Returns every preset key in display order (rolling windows first, then the
period-to-date presets), for rendering the preset radio list.
"""
@spec all() :: [preset()]
@spec all() :: [preset(), ...]
def all, do: @presets
@doc """

View file

@ -101,7 +101,12 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do
maybe_fee_type(fee_types) ++
[
%{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range},
%{key: :exit_date, group: :membership, label: gettext("Exit date"), control: :exit_date}
%{
key: :exit_date,
group: :membership,
label: gettext("Exit date"),
control: :active_former
}
]
end

View file

@ -81,7 +81,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
(at least N unpaid cycles in the period)
* anything else `nil` (no payment-count filter)
"""
@spec parse_filter(term()) :: 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}
@ -212,17 +212,31 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@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.
(`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("Fees across all outstanding cycles")
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("Fees in period %{from}%{to}", from: period_bound(from), to: period_bound(to))
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("Fees across all outstanding cycles")
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: ""