Rework Filter using a filter builder #556
11 changed files with 113 additions and 112 deletions
|
|
@ -50,7 +50,7 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@suspended_param "suspended"
|
@suspended_param "suspended"
|
||||||
|
|
||||||
@stichtag_param "stichtag"
|
@as_of_date_param "as_of_date"
|
||||||
|
|
||||||
@max_boolean_filters 50
|
@max_boolean_filters 50
|
||||||
|
|
||||||
|
|
@ -240,16 +240,16 @@ defmodule Mv.Constants do
|
||||||
def suspended_param, do: @suspended_param
|
def suspended_param, do: @suspended_param
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the URL parameter name for the point-in-time membership ("Stichtag")
|
Returns the URL parameter name for the point-in-time "active on X" membership
|
||||||
filter: keeps members who were active on the given ISO-8601 date X, i.e.
|
filter: keeps members who were active on the given ISO-8601 date X, i.e.
|
||||||
`join_date <= X and (exit_date is nil or exit_date > X)`.
|
`join_date <= X and (exit_date is nil or exit_date > X)`.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> Mv.Constants.stichtag_param()
|
iex> Mv.Constants.as_of_date_param()
|
||||||
"stichtag"
|
"as_of_date"
|
||||||
"""
|
"""
|
||||||
def stichtag_param, do: @stichtag_param
|
def as_of_date_param, do: @as_of_date_param
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the maximum number of boolean custom field filters allowed per request.
|
Returns the maximum number of boolean custom field filters allowed per request.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
* `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}`
|
* `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}`
|
||||||
* `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}`
|
* `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}`
|
||||||
* `{:suspended_changed, boolean()}`
|
* `{:suspended_changed, boolean()}`
|
||||||
* `{:stichtag_changed, Date.t() | nil}`
|
* `{:as_of_date_changed, Date.t() | nil}`
|
||||||
|
|
||||||
and `{:clear_all_filters}` for Clear all.
|
and `{:clear_all_filters}` for Clear all.
|
||||||
"""
|
"""
|
||||||
|
|
@ -91,7 +91,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
|> assign(:payment_filter, assigns[:payment_filter])
|
|> assign(:payment_filter, assigns[:payment_filter])
|
||||||
|> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period())
|
|> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period())
|
||||||
|> assign(:suspended, assigns[:suspended] || false)
|
|> assign(:suspended, assigns[:suspended] || false)
|
||||||
|> assign(:stichtag, assigns[:stichtag])
|
|> assign(:as_of_date, assigns[:as_of_date])
|
||||||
end
|
end
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -461,23 +461,23 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<%!-- Separate paragraph (not part of the Active/Former button-join): the
|
<%!-- Separate paragraph (not part of the Active/Former button-join): the
|
||||||
point-in-time "active on X" Stichtag filter (§1.24). --%>
|
point-in-time "active on X" as-of-date filter (§1.24). --%>
|
||||||
<div class="flex flex-col gap-1" data-testid="stichtag-paragraph">
|
<div class="flex flex-col gap-1" data-testid="as-of-date-paragraph">
|
||||||
<.input
|
<.input
|
||||||
type="date"
|
type="date"
|
||||||
name="stichtag"
|
name="as_of_date"
|
||||||
label={gettext("Active on reference date")}
|
label={gettext("Active on reference date")}
|
||||||
class="input input-sm input-bordered"
|
class="input input-sm input-bordered"
|
||||||
value={date_iso(@stichtag)}
|
value={date_iso(@as_of_date)}
|
||||||
data-testid="stichtag-input"
|
data-testid="as-of-date-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%!-- Third paragraph "Eigener Zeitraum": the same native From/To range the
|
<%!-- Third paragraph, a custom range: the same native From/To range the
|
||||||
join-date builder uses, wired to the exit_date custom mode — members whose
|
join-date builder uses, wired to the exit_date custom mode — members whose
|
||||||
exit date falls within [from, to] (§1.24). A sibling form (not the
|
exit date falls within [from, to] (§1.24). A sibling form (not the
|
||||||
quick/Stichtag one) so committing a bound only touches the exit_date
|
quick/as-of-date one) so committing a bound only touches the exit_date
|
||||||
slice and never re-reads the quick radios. --%>
|
slice and never re-reads the quick radios. --%>
|
||||||
<form
|
<form
|
||||||
phx-change="commit"
|
phx-change="commit"
|
||||||
|
|
@ -614,7 +614,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
{local_range(@from_val, @to_val)}
|
{local_range(@from_val, @to_val)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%!-- Separate "Eigener Zeitraum" paragraph: two native <input type="date">
|
<%!-- Separate custom-range paragraph: two native <input type="date">
|
||||||
controls side by side (§3.12 native-first — no cally). Committing a bound
|
controls side by side (§3.12 native-first — no cally). Committing a bound
|
||||||
through the shared date dispatch applies the custom range; the browser's
|
through the shared date dispatch applies the custom range; the browser's
|
||||||
own date popup gives year-jump, manual entry and WCAG a11y for free. --%>
|
own date popup gives year-jump, manual entry and WCAG a11y for free. --%>
|
||||||
|
|
@ -722,7 +722,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
def handle_event("commit", params, socket) do
|
def handle_event("commit", params, socket) do
|
||||||
dispatch_payment(socket, params)
|
dispatch_payment(socket, params)
|
||||||
dispatch_quick(socket, params)
|
dispatch_quick(socket, params)
|
||||||
dispatch_stichtag(socket, params)
|
dispatch_as_of_date(socket, params)
|
||||||
dispatch_exit_range(socket, params)
|
dispatch_exit_range(socket, params)
|
||||||
|
|
||||||
dispatch_prefix(
|
dispatch_prefix(
|
||||||
|
|
@ -824,14 +824,14 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch_stichtag(socket, params) do
|
defp dispatch_as_of_date(socket, params) do
|
||||||
if Map.has_key?(params, "stichtag") do
|
if Map.has_key?(params, "as_of_date") do
|
||||||
date = parse_date(Map.get(params, "stichtag"))
|
date = parse_date(Map.get(params, "as_of_date"))
|
||||||
maybe_send(date != socket.assigns.stichtag, {:stichtag_changed, date})
|
maybe_send(date != socket.assigns.as_of_date, {:as_of_date_changed, date})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# The exit-date "Eigener Zeitraum" range writes only the exit_date slice
|
# The exit-date custom range writes only the exit_date slice
|
||||||
# (mode :custom + bounds), preserving join_date and custom-date entries — so
|
# (mode :custom + bounds), preserving join_date and custom-date entries — so
|
||||||
# editing the exit range never wipes another active date filter. Committing an
|
# editing the exit range never wipes another active date filter. Committing an
|
||||||
# empty range simply parks exit_date in :custom with nil bounds (no chip, no
|
# empty range simply parks exit_date in :custom with nil bounds (no chip, no
|
||||||
|
|
@ -917,7 +917,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp remove_chip("suspended", _id, _socket), do: send(self(), {:suspended_changed, false})
|
defp remove_chip("suspended", _id, _socket), do: send(self(), {:suspended_changed, false})
|
||||||
defp remove_chip("stichtag", _id, _socket), do: send(self(), {:stichtag_changed, nil})
|
defp remove_chip("as_of_date", _id, _socket), do: send(self(), {:as_of_date_changed, nil})
|
||||||
|
|
||||||
defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil})
|
defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil})
|
||||||
defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil})
|
defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil})
|
||||||
|
|
@ -947,7 +947,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
fee_type_chips(assigns) ++
|
fee_type_chips(assigns) ++
|
||||||
boolean_chips(assigns) ++
|
boolean_chips(assigns) ++
|
||||||
date_chips(assigns) ++
|
date_chips(assigns) ++
|
||||||
stichtag_chip(assigns) ++
|
as_of_date_chip(assigns) ++
|
||||||
payment_chips(assigns) ++
|
payment_chips(assigns) ++
|
||||||
suspended_chip(assigns)
|
suspended_chip(assigns)
|
||||||
end
|
end
|
||||||
|
|
@ -1117,18 +1117,18 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
|
|
||||||
defp suspended_chip(_), do: []
|
defp suspended_chip(_), do: []
|
||||||
|
|
||||||
defp stichtag_chip(%{stichtag: %Date{} = date}) do
|
defp as_of_date_chip(%{as_of_date: %Date{} = date}) do
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
kind: "stichtag",
|
kind: "as_of_date",
|
||||||
id: "stichtag",
|
id: "as_of_date",
|
||||||
edit_key: "active_former",
|
edit_key: "active_former",
|
||||||
label: gettext("Active on %{date}", date: DateFormatter.format_date(date))
|
label: gettext("Active on %{date}", date: DateFormatter.format_date(date))
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stichtag_chip(_), do: []
|
defp as_of_date_chip(_), do: []
|
||||||
|
|
||||||
defp unpaid_range_label(min, nil) when min <= 1, do: gettext("Open contributions")
|
defp unpaid_range_label(min, nil) when min <= 1, do: gettext("Open contributions")
|
||||||
defp unpaid_range_label(min, nil), do: gettext("≥ %{n} unpaid", n: min)
|
defp unpaid_range_label(min, nil), do: gettext("≥ %{n} unpaid", n: min)
|
||||||
|
|
@ -1235,7 +1235,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
defp date_iso(_), do: nil
|
defp date_iso(_), do: nil
|
||||||
|
|
||||||
# ISO string for a bound of the exit_date custom range, pre-filling the
|
# ISO string for a bound of the exit_date custom range, pre-filling the
|
||||||
# "Eigener Zeitraum" inputs only when the exit filter is actually in :custom
|
# custom-range inputs only when the exit filter is actually in :custom
|
||||||
# mode (the quick active/former/all states carry no user range).
|
# mode (the quick active/former/all states carry no user range).
|
||||||
defp exit_bound_iso(filters, bound) do
|
defp exit_bound_iso(filters, bound) do
|
||||||
case Map.get(filters, :exit_date, %{}) do
|
case Map.get(filters, :exit_date, %{}) do
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
alias MvWeb.MemberLive.Index.AsOfDate
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
alias MvWeb.MemberLive.Index.ExportPayload
|
alias MvWeb.MemberLive.Index.ExportPayload
|
||||||
|
|
@ -59,7 +60,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
alias MvWeb.MemberLive.Index.PaymentAging
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
alias MvWeb.MemberLive.Index.Stichtag
|
|
||||||
alias MvWeb.MemberLive.Index.ViewSettings
|
alias MvWeb.MemberLive.Index.ViewSettings
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -179,7 +179,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:payment_filter, nil)
|
|> assign(:payment_filter, nil)
|
||||||
|> assign(:payment_period, PaymentAging.default_period())
|
|> assign(:payment_period, PaymentAging.default_period())
|
||||||
|> assign(:suspended, false)
|
|> assign(:suspended, false)
|
||||||
|> assign(:stichtag, nil)
|
|> assign(:as_of_date, nil)
|
||||||
|> assign(:group_filters, %{})
|
|> assign(:group_filters, %{})
|
||||||
|> assign(:groups, groups)
|
|> assign(:groups, groups)
|
||||||
|> assign(:fee_type_filters, %{})
|
|> assign(:fee_type_filters, %{})
|
||||||
|
|
@ -473,7 +473,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
payment_filter: nil,
|
payment_filter: nil,
|
||||||
payment_period: PaymentAging.default_period(),
|
payment_period: PaymentAging.default_period(),
|
||||||
suspended: false,
|
suspended: false,
|
||||||
stichtag: nil,
|
as_of_date: nil,
|
||||||
group_filters: %{},
|
group_filters: %{},
|
||||||
fee_type_filters: %{},
|
fee_type_filters: %{},
|
||||||
boolean_filters: %{},
|
boolean_filters: %{},
|
||||||
|
|
@ -504,16 +504,16 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:stichtag_changed, stichtag}, socket) do
|
def handle_info({:as_of_date_changed, as_of_date}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:stichtag, stichtag)
|
|> assign(:as_of_date, as_of_date)
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> scroll_list_to_top()
|
|> scroll_list_to_top()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(opts_for_query_params(socket, %{stichtag: stichtag}))
|
build_query_params(opts_for_query_params(socket, %{as_of_date: as_of_date}))
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -638,7 +638,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||||||
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
||||||
|> assign(:suspended, Map.get(opts, :suspended, false))
|
|> assign(:suspended, Map.get(opts, :suspended, false))
|
||||||
|> assign(:stichtag, Map.get(opts, :stichtag))
|
|> assign(:as_of_date, Map.get(opts, :as_of_date))
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> scroll_list_to_top()
|
|> scroll_list_to_top()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
@ -727,7 +727,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_payment_filter(params)
|
|> maybe_update_payment_filter(params)
|
||||||
|> maybe_update_payment_period(params)
|
|> maybe_update_payment_period(params)
|
||||||
|> maybe_update_suspended(params)
|
|> maybe_update_suspended(params)
|
||||||
|> maybe_update_stichtag(params)
|
|> maybe_update_as_of_date(params)
|
||||||
|> maybe_update_group_filters(params)
|
|> maybe_update_group_filters(params)
|
||||||
|> maybe_update_fee_type_filters(params)
|
|> maybe_update_fee_type_filters(params)
|
||||||
|> maybe_update_boolean_filters(params)
|
|> maybe_update_boolean_filters(params)
|
||||||
|
|
@ -778,7 +778,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.payment_filter,
|
socket.assigns.payment_filter,
|
||||||
socket.assigns.payment_period,
|
socket.assigns.payment_period,
|
||||||
socket.assigns[:suspended],
|
socket.assigns[:suspended],
|
||||||
socket.assigns[:stichtag],
|
socket.assigns[:as_of_date],
|
||||||
socket.assigns[:group_filters],
|
socket.assigns[:group_filters],
|
||||||
socket.assigns[:fee_type_filters],
|
socket.assigns[:fee_type_filters],
|
||||||
socket.assigns.boolean_custom_field_filters,
|
socket.assigns.boolean_custom_field_filters,
|
||||||
|
|
@ -934,17 +934,17 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
||||||
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
||||||
base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period)
|
base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period)
|
||||||
base_params = add_status_params(base_params, opts[:suspended], opts[:stichtag])
|
base_params = add_status_params(base_params, opts[:suspended], opts[:as_of_date])
|
||||||
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||||
add_date_filters(base_params, opts.date_filters)
|
add_date_filters(base_params, opts.date_filters)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Suspended-status flag and Stichtag date serialize additively into the flat
|
# Suspended-status flag and as-of date serialize additively into the flat
|
||||||
# URL contract (suspended=1 / stichtag=<iso>), omitted when inactive (§2.1).
|
# URL contract (suspended=1 / as_of_date=<iso>), omitted when inactive (§2.1).
|
||||||
defp add_status_params(params, suspended, stichtag) do
|
defp add_status_params(params, suspended, as_of_date) do
|
||||||
params
|
params
|
||||||
|> Map.merge(PaymentAging.suspended_to_params(suspended))
|
|> Map.merge(PaymentAging.suspended_to_params(suspended))
|
||||||
|> Map.merge(Stichtag.to_params(stichtag))
|
|> Map.merge(AsOfDate.to_params(as_of_date))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Period-scoped payment model (§3.3): the payment-count filter and the active
|
# Period-scoped payment model (§3.3): the payment-count filter and the active
|
||||||
|
|
@ -967,7 +967,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
payment_filter: socket.assigns.payment_filter,
|
payment_filter: socket.assigns.payment_filter,
|
||||||
payment_period: socket.assigns.payment_period,
|
payment_period: socket.assigns.payment_period,
|
||||||
suspended: socket.assigns[:suspended] || false,
|
suspended: socket.assigns[:suspended] || false,
|
||||||
stichtag: socket.assigns[:stichtag],
|
as_of_date: socket.assigns[:as_of_date],
|
||||||
group_filters: socket.assigns[:group_filters] || %{},
|
group_filters: socket.assigns[:group_filters] || %{},
|
||||||
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
||||||
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
||||||
|
|
@ -1429,7 +1429,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
payment_filter: socket.assigns.payment_filter,
|
payment_filter: socket.assigns.payment_filter,
|
||||||
payment_period: socket.assigns.payment_period,
|
payment_period: socket.assigns.payment_period,
|
||||||
suspended: socket.assigns[:suspended] || false,
|
suspended: socket.assigns[:suspended] || false,
|
||||||
stichtag: socket.assigns[:stichtag],
|
as_of_date: socket.assigns[:as_of_date],
|
||||||
sort_field: socket.assigns.sort_field,
|
sort_field: socket.assigns.sort_field,
|
||||||
sort_order: socket.assigns.sort_order,
|
sort_order: socket.assigns.sort_order,
|
||||||
custom_fields: socket.assigns.all_custom_fields
|
custom_fields: socket.assigns.all_custom_fields
|
||||||
|
|
@ -1644,14 +1644,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp maybe_update_payment_period(socket, params),
|
defp maybe_update_payment_period(socket, params),
|
||||||
do: assign(socket, :payment_period, PaymentAging.parse_period(params))
|
do: assign(socket, :payment_period, PaymentAging.parse_period(params))
|
||||||
|
|
||||||
# Suspended-status filter (§1.23) and point-in-time Stichtag (§1.24/§2.6):
|
# Suspended-status filter (§1.23) and point-in-time as-of date (§1.24/§2.6):
|
||||||
# both decoded straight from the URL so `handle_params` remains the single
|
# both decoded straight from the URL so `handle_params` remains the single
|
||||||
# source of truth. Absent params fall back to no filter.
|
# source of truth. Absent params fall back to no filter.
|
||||||
defp maybe_update_suspended(socket, params),
|
defp maybe_update_suspended(socket, params),
|
||||||
do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
|
do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
|
||||||
|
|
||||||
defp maybe_update_stichtag(socket, params),
|
defp maybe_update_as_of_date(socket, params),
|
||||||
do: assign(socket, :stichtag, Stichtag.parse(params))
|
do: assign(socket, :as_of_date, AsOfDate.parse(params))
|
||||||
|
|
||||||
defp maybe_update_group_filters(socket, params) when is_map(params) do
|
defp maybe_update_group_filters(socket, params) when is_map(params) do
|
||||||
prefix = @group_filter_prefix
|
prefix = @group_filter_prefix
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
payment_filter={@payment_filter}
|
payment_filter={@payment_filter}
|
||||||
payment_period={@payment_period}
|
payment_period={@payment_period}
|
||||||
suspended={@suspended}
|
suspended={@suspended}
|
||||||
stichtag={@stichtag}
|
as_of_date={@as_of_date}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
37
lib/mv_web/live/member_live/index/as_of_date.ex
Normal file
37
lib/mv_web/live/member_live/index/as_of_date.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.AsOfDate do
|
||||||
|
@moduledoc """
|
||||||
|
URL codec for the point-in-time "active on X" membership filter (§1.24/§2.6).
|
||||||
|
|
||||||
|
The as-of date is a single ISO-8601 date X carried in the flat URL contract
|
||||||
|
under the `as_of_date` key. When set, the overview keeps members who were
|
||||||
|
active on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the
|
||||||
|
predicate itself lives in `OverviewQuery`. Absent or malformed values decode
|
||||||
|
to nil (no filter), and nil encodes to the empty map so a fresh URL stays
|
||||||
|
canonical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@as_of_date_param Mv.Constants.as_of_date_param()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decodes the as-of date from a params map. Missing or malformed ISO-8601
|
||||||
|
values yield nil.
|
||||||
|
"""
|
||||||
|
@spec parse(map()) :: Date.t() | nil
|
||||||
|
def parse(params) when is_map(params), do: parse_date(Map.get(params, @as_of_date_param))
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Encodes an as-of date into URL params. nil yields the empty map.
|
||||||
|
"""
|
||||||
|
@spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()}
|
||||||
|
def to_params(%Date{} = date), do: %{@as_of_date_param => Date.to_iso8601(date)}
|
||||||
|
def to_params(_), do: %{}
|
||||||
|
|
||||||
|
defp parse_date(value) when is_binary(value) do
|
||||||
|
case Date.from_iso8601(String.trim(value)) do
|
||||||
|
{:ok, date} -> date
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_date(_), do: nil
|
||||||
|
end
|
||||||
|
|
@ -46,7 +46,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
opts[:boolean_custom_fields]
|
opts[:boolean_custom_fields]
|
||||||
)
|
)
|
||||||
|> apply_date_filters(opts[:date_filters])
|
|> apply_date_filters(opts[:date_filters])
|
||||||
|> apply_stichtag_filter(opts[:stichtag])
|
|> apply_as_of_date_filter(opts[:as_of_date])
|
||||||
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||||
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||||
|> apply_suspended_filter(opts[:suspended], payment_period(opts))
|
|> apply_suspended_filter(opts[:suspended], payment_period(opts))
|
||||||
|
|
@ -285,20 +285,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
|
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Point-in-time membership ("Stichtag"): a member counts as a member on the
|
# Point-in-time membership (as-of date): a member counts as a member on the
|
||||||
# reference date X iff it had joined by X and had not yet left as of X
|
# reference date X iff it had joined by X and had not yet left as of X
|
||||||
# (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an
|
# (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an
|
||||||
# exit_date strictly after X (or nil) still counts as a member on X.
|
# exit_date strictly after X (or nil) still counts as a member on X.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
defp apply_stichtag_filter(query, %Date{} = x) do
|
defp apply_as_of_date_filter(query, %Date{} = x) do
|
||||||
Ash.Query.filter(
|
Ash.Query.filter(
|
||||||
query,
|
query,
|
||||||
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
|
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_stichtag_filter(query, _), do: query
|
defp apply_as_of_date_filter(query, _), do: query
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sort (always append the unique id tie-breaker for keyset stability)
|
# Sort (always append the unique id tie-breaker for keyset stability)
|
||||||
|
|
@ -327,7 +327,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
])
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
# "Beiträge" (payment) column: sort DB-side by the period-scoped unpaid-cycle
|
# Fees (payment) column: sort DB-side by the period-scoped unpaid-cycle
|
||||||
# count (§1.27). The count calculation takes the active period as arguments so
|
# count (§1.27). The count calculation takes the active period as arguments so
|
||||||
# the sort key matches the displayed value; the id tie-breaker keeps keyset
|
# the sort key matches the displayed value; the id tie-breaker keeps keyset
|
||||||
# pages stable across equal counts.
|
# pages stable across equal counts.
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.Stichtag do
|
|
||||||
@moduledoc """
|
|
||||||
URL codec for the point-in-time membership ("Stichtag") filter (§1.24/§2.6).
|
|
||||||
|
|
||||||
The Stichtag is a single ISO-8601 date X carried in the flat URL contract
|
|
||||||
under the `stichtag` key. When set, the overview keeps members who were active
|
|
||||||
on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the predicate
|
|
||||||
itself lives in `OverviewQuery`. Absent or malformed values decode to nil (no
|
|
||||||
filter), and nil encodes to the empty map so a fresh URL stays canonical.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@stichtag_param Mv.Constants.stichtag_param()
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Decodes the Stichtag date from a params map. Missing or malformed ISO-8601
|
|
||||||
values yield nil.
|
|
||||||
"""
|
|
||||||
@spec parse(map()) :: Date.t() | nil
|
|
||||||
def parse(params) when is_map(params), do: parse_date(Map.get(params, @stichtag_param))
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Encodes a Stichtag date into URL params. nil yields the empty map.
|
|
||||||
"""
|
|
||||||
@spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()}
|
|
||||||
def to_params(%Date{} = date), do: %{@stichtag_param => Date.to_iso8601(date)}
|
|
||||||
def to_params(_), do: %{}
|
|
||||||
|
|
||||||
defp parse_date(value) when is_binary(value) do
|
|
||||||
case Date.from_iso8601(String.trim(value)) do
|
|
||||||
{:ok, date} -> date
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_date(_), do: nil
|
|
||||||
end
|
|
||||||
|
|
@ -62,16 +62,16 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do
|
||||||
assert has_element?(view, "#row-#{ctx.former.id}")
|
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Stichtag paragraph (§1.24)" do
|
describe "as-of-date paragraph (§1.24)" do
|
||||||
test "the active/former control carries a separate 'active on reference date' input", ctx do
|
test "the active/former control carries a separate 'active on reference date' input", ctx do
|
||||||
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||||
|
|
||||||
pick_field(view, "active_former")
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
# The Stichtag input sits in its own paragraph, not inside the
|
# The as-of-date input sits in its own paragraph, not inside the
|
||||||
# Active/Former button-join.
|
# Active/Former button-join.
|
||||||
assert has_element?(view, "[data-testid='stichtag-paragraph'] input[name='stichtag']")
|
assert has_element?(view, "[data-testid='as-of-date-paragraph'] input[name='as_of_date']")
|
||||||
refute has_element?(view, ".join input[name='stichtag']")
|
refute has_element?(view, ".join input[name='as_of_date']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "setting a reference date activates the point-in-time membership filter", ctx do
|
test "setting a reference date activates the point-in-time membership filter", ctx do
|
||||||
|
|
@ -81,22 +81,22 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("[data-testid='value-control-active-former']")
|
|> element("[data-testid='value-control-active-former']")
|
||||||
|> render_change(%{"quick" => "active", "stichtag" => "2024-06-15"})
|
|> render_change(%{"quick" => "active", "as_of_date" => "2024-06-15"})
|
||||||
|
|
||||||
_ = render(view)
|
_ = render(view)
|
||||||
path = assert_patch(view)
|
path = assert_patch(view)
|
||||||
assert path =~ "stichtag=2024-06-15"
|
assert path =~ "as_of_date=2024-06-15"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Eigener Zeitraum exit-date range (§1.24)" do
|
describe "custom exit-date range (§1.24)" do
|
||||||
test "the exit-date control carries a separate native From/To range paragraph", ctx do
|
test "the exit-date control carries a separate native From/To range paragraph", ctx do
|
||||||
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||||
|
|
||||||
pick_field(view, "active_former")
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
# Third paragraph, same native two-input range control the join-date
|
# Third paragraph, same native two-input range control the join-date
|
||||||
# builder uses — separate from the Active/Former join and the Stichtag box.
|
# builder uses — separate from the Active/Former join and the as-of-date box.
|
||||||
assert has_element?(view, "[data-testid='value-control-exit-range']")
|
assert has_element?(view, "[data-testid='value-control-exit-range']")
|
||||||
|
|
||||||
assert has_element?(
|
assert has_element?(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
|
defmodule MvWeb.MemberLive.IndexAsOfDatePropertyTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
§2.6 — Point-in-time membership ("member on the reference date X").
|
§2.6 — Point-in-time membership ("member on the reference date X").
|
||||||
|
|
||||||
For arbitrary (join_date, exit_date) pairs and reference dates X, the Stichtag
|
For arbitrary (join_date, exit_date) pairs and reference dates X, the as-of-date
|
||||||
filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`.
|
filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`.
|
||||||
Verified at the `OverviewQuery` layer against an in-memory oracle.
|
Verified at the `OverviewQuery` layer against an in-memory oracle.
|
||||||
"""
|
"""
|
||||||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
property "Stichtag filter keeps exactly the members active on X" do
|
property "as-of-date filter keeps exactly the members active on X" do
|
||||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
check all(
|
check all(
|
||||||
|
|
@ -67,7 +67,7 @@ defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do
|
||||||
do: id
|
do: id
|
||||||
|
|
||||||
actual =
|
actual =
|
||||||
%{stichtag: x}
|
%{as_of_date: x}
|
||||||
|> OverviewQuery.build()
|
|> OverviewQuery.build()
|
||||||
|> Ash.read!(actor: actor)
|
|> Ash.read!(actor: actor)
|
||||||
|> MapSet.new(& &1.id)
|
|> MapSet.new(& &1.id)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
|
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
§2.1 (extended) — the v2 filter keys survive the LiveView decode→encode round
|
§2.1 (extended) — the v2 filter keys survive the LiveView decode→encode round
|
||||||
trip. Loading `/members` with a Stichtag date, the suspended flag, and a
|
trip. Loading `/members` with an as-of date, the suspended flag, and a
|
||||||
two-sided payment count range, then triggering an unrelated re-patch (a sort
|
two-sided payment count range, then triggering an unrelated re-patch (a sort
|
||||||
click), preserves every one of those keys in the pushed URL.
|
click), preserves every one of those keys in the pushed URL.
|
||||||
"""
|
"""
|
||||||
|
|
@ -21,15 +21,15 @@ defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
|
||||||
%{conn: conn_with_oidc_user(conn)}
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "stichtag, suspended and payment count range persist through a re-patch", %{conn: conn} do
|
test "as-of date, suspended and payment count range persist through a re-patch", %{conn: conn} do
|
||||||
url = "/members?stichtag=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4"
|
url = "/members?as_of_date=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4"
|
||||||
{:ok, view, _html} = live(conn, url)
|
{:ok, view, _html} = live(conn, url)
|
||||||
|
|
||||||
# Trigger an unrelated URL re-patch by sorting a stable column.
|
# Trigger an unrelated URL re-patch by sorting a stable column.
|
||||||
view |> element("[data-testid='payment']") |> render_click()
|
view |> element("[data-testid='payment']") |> render_click()
|
||||||
path = assert_patch(view)
|
path = assert_patch(view)
|
||||||
|
|
||||||
assert path =~ "stichtag=2026-03-15"
|
assert path =~ "as_of_date=2026-03-15"
|
||||||
assert path =~ "suspended=1"
|
assert path =~ "suspended=1"
|
||||||
assert path =~ "pay_filter=has_unpaid"
|
assert path =~ "pay_filter=has_unpaid"
|
||||||
assert path =~ "pay_min=2"
|
assert path =~ "pay_min=2"
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
|
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
§2.1 (extended) — URL codec round-trip for the new v2 filter keys: the
|
§2.1 (extended) — URL codec round-trip for the new v2 filter keys: the
|
||||||
point-in-time Stichtag date and the suspended-status flag. Encoding a state to
|
point-in-time as-of date and the suspended-status flag. Encoding a state to
|
||||||
params then decoding yields the canonical state, and the default (no filter)
|
params then decoding yields the canonical state, and the default (no filter)
|
||||||
encodes to the empty map so a fresh URL is canonical.
|
encodes to the empty map so a fresh URL is canonical.
|
||||||
"""
|
"""
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.AsOfDate
|
||||||
alias MvWeb.MemberLive.Index.PaymentAging
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
alias MvWeb.MemberLive.Index.Stichtag
|
|
||||||
|
|
||||||
describe "Stichtag date" do
|
describe "as-of date" do
|
||||||
test "a set date round-trips" do
|
test "a set date round-trips" do
|
||||||
date = ~D[2026-03-15]
|
date = ~D[2026-03-15]
|
||||||
assert date |> Stichtag.to_params() |> Stichtag.parse() == date
|
assert date |> AsOfDate.to_params() |> AsOfDate.parse() == date
|
||||||
end
|
end
|
||||||
|
|
||||||
test "nil encodes to the empty map and decodes to nil" do
|
test "nil encodes to the empty map and decodes to nil" do
|
||||||
assert Stichtag.to_params(nil) == %{}
|
assert AsOfDate.to_params(nil) == %{}
|
||||||
assert Stichtag.parse(%{}) == nil
|
assert AsOfDate.parse(%{}) == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "a malformed date param decodes to nil" do
|
test "a malformed date param decodes to nil" do
|
||||||
assert Stichtag.parse(%{"stichtag" => "not-a-date"}) == nil
|
assert AsOfDate.parse(%{"as_of_date" => "not-a-date"}) == nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue