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:
parent
c0d1550401
commit
8303a39041
23 changed files with 1905 additions and 565 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "…"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue