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

@ -36,6 +36,7 @@ defmodule MvWeb.ConnCase do
import Plug.Conn
import Phoenix.ConnTest
import MvWeb.ConnCase
import MvWeb.FilterBuilderHelpers
end
end

View file

@ -0,0 +1,83 @@
defmodule MvWeb.FilterBuilderHelpers do
@moduledoc """
Test helpers for driving the member-overview `AddFilterBuilderComponent`.
The add-filter flow is: open the picker pick a field change the focused
value control (which commits the filter and re-loads the list). These helpers
encapsulate that sequence so tests read at the intent level and stay robust to
the builder's internal markup.
Each `apply_*` returns the patched path (the new `/members?` URL) so callers
can assert the URL-encoded filter contract.
"""
import Phoenix.LiveViewTest
@builder_id "member-filter"
@doc "Opens the field picker."
def open_picker(view) do
view |> element("[data-testid='add-filter-trigger']") |> render_click()
end
@doc "Opens the picker and selects the field with the given descriptor key."
def pick_field(view, key) do
open_picker(view)
view |> element("##{@builder_id}-field-opt-#{key}") |> render_click()
end
@doc "Applies a group in/not_in/all filter through the builder."
def apply_group_filter(view, group_id, value) do
pick_field(view, "group")
view
|> element("[data-testid='value-control-group']")
|> render_change(%{"group_#{group_id}" => to_string(value)})
flush_and_patch(view)
end
@doc "Applies a fee-type in/not_in/all filter through the builder."
def apply_fee_type_filter(view, fee_type_id, value) do
pick_field(view, "fee_type")
view
|> element("[data-testid='value-control-fee-type']")
|> render_change(%{"fee_type_#{fee_type_id}" => to_string(value)})
flush_and_patch(view)
end
@doc "Applies a boolean custom-field filter (true/false/all) through the builder."
def apply_boolean_filter(view, field_id, value) do
pick_field(view, field_id)
view
|> element("[data-testid='value-control-boolean']")
|> render_change(%{"custom_boolean" => %{"#{field_id}" => to_string(value)}})
flush_and_patch(view)
end
@doc """
Applies a payment-count filter (and optionally a period) through the builder.
`filter` is one of `"all"`, `"fully_paid"`, `"unpaid_1"`, `"unpaid_2"`,
`"unpaid_3"`; `from`/`to` are ISO-8601 strings or `""`.
"""
def apply_payment_filter(view, filter, from \\ "", to \\ "") do
pick_field(view, "payment")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{"pay" => filter, "pay_from" => from, "pay_to" => to})
flush_and_patch(view)
end
# Forces the LiveView to process the queued filter message(s) and returns the
# resulting patched path.
defp flush_and_patch(view) do
_ = render(view)
assert_patch(view)
end
end