feat(overview): add period-scoped payment filter and field-picker descriptor
This commit is contained in:
parent
95ef3177ac
commit
7d1a71b1fd
12 changed files with 654 additions and 0 deletions
|
|
@ -67,6 +67,45 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@typedoc """
|
||||||
|
The three-state active/former quick filter. It is not a stored field but a
|
||||||
|
view onto `exit_date.mode`: `:active` ↔ `:active_only`, `:former` ↔
|
||||||
|
`:inactive_only`, `:all` ↔ `:all`. A detailed `:custom` exit-date selection
|
||||||
|
has no quick equivalent and surfaces as `:custom` (no quick chip selected).
|
||||||
|
"""
|
||||||
|
@type quick_state :: :active | :former | :all | :custom
|
||||||
|
|
||||||
|
@quick_to_mode %{active: :active_only, former: :inactive_only, all: :all}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Derives the active/former quick-filter state from the single `exit_date`
|
||||||
|
source. Total function; an absent or malformed `exit_date` reads as the
|
||||||
|
default `:active`. This is the only reader — there is no second state to keep
|
||||||
|
in sync (§2.4).
|
||||||
|
"""
|
||||||
|
@spec quick_state(map()) :: quick_state()
|
||||||
|
def quick_state(filters) when is_map(filters) do
|
||||||
|
case Map.get(filters, :exit_date, %{}) do
|
||||||
|
%{mode: :active_only} -> :active
|
||||||
|
%{mode: :inactive_only} -> :former
|
||||||
|
%{mode: :all} -> :all
|
||||||
|
%{mode: :custom} -> :custom
|
||||||
|
_ -> :active
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Applies an active/former quick-filter choice onto the date filter state by
|
||||||
|
writing the corresponding `exit_date` mode and clearing its bounds. Only the
|
||||||
|
`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()
|
||||||
|
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})
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Decodes URL params into a date filter state map.
|
Decodes URL params into a date filter state map.
|
||||||
|
|
||||||
|
|
|
||||||
140
lib/mv_web/live/member_live/index/filter_descriptor.ex
Normal file
140
lib/mv_web/live/member_live/index/filter_descriptor.ex
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.FilterDescriptor do
|
||||||
|
@moduledoc """
|
||||||
|
Data-driven catalog of the filter fields offered by the member-overview
|
||||||
|
add-filter builder.
|
||||||
|
|
||||||
|
Each descriptor is a plain, serializable map describing one pickable field:
|
||||||
|
|
||||||
|
%{
|
||||||
|
key: atom() | String.t(), # stable identifier (custom fields use their UUID string)
|
||||||
|
group: :quick | :membership | :custom_fields,
|
||||||
|
label: String.t(), # human-readable, gettext-translated
|
||||||
|
control: atom() # which type-aware value control the builder renders
|
||||||
|
}
|
||||||
|
|
||||||
|
The catalog is the persistence basis the saved-views work (#549) docks onto:
|
||||||
|
it is derived purely from the field context (groups, fee types, custom fields)
|
||||||
|
and carries no UI or query state.
|
||||||
|
|
||||||
|
Grouping (confirmed taxonomy):
|
||||||
|
|
||||||
|
* `:quick` — Payment, Active/former (common shortcuts)
|
||||||
|
* `:membership` — Group, Fee type, Join date, Exit date
|
||||||
|
* `:custom_fields` — one entry per filterable club-defined custom field
|
||||||
|
(boolean and date fields; other value types are not filtered on the
|
||||||
|
overview)
|
||||||
|
|
||||||
|
A group with no descriptors is omitted entirely (no header) — see
|
||||||
|
`visible_groups/1`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
@type group :: :quick | :membership | :custom_fields
|
||||||
|
|
||||||
|
@type t :: %{
|
||||||
|
key: atom() | String.t(),
|
||||||
|
group: group(),
|
||||||
|
label: String.t(),
|
||||||
|
control: atom()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fixed display order of the groups in the picker.
|
||||||
|
@group_order [:quick, :membership, :custom_fields]
|
||||||
|
|
||||||
|
# Custom-field value types the overview can filter on.
|
||||||
|
@filterable_custom_field_types [:boolean, :date]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the ordered list of all descriptors available for the given field
|
||||||
|
context. Recognised context keys (all optional, default empty):
|
||||||
|
|
||||||
|
* `:groups` — list of `%{id: _, name: _}` group structs
|
||||||
|
* `:fee_types` — list of fee-type structs
|
||||||
|
* `:custom_fields` — list of custom-field structs (`:value_type`, `:name`, `:id`)
|
||||||
|
"""
|
||||||
|
@spec all(map()) :: [t()]
|
||||||
|
def all(context) when is_map(context) do
|
||||||
|
quick() ++
|
||||||
|
membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++
|
||||||
|
custom_fields(Map.get(context, :custom_fields, []))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "The group an individual descriptor belongs to."
|
||||||
|
@spec group_for(t()) :: group()
|
||||||
|
def group_for(%{group: group}), do: group
|
||||||
|
|
||||||
|
@doc "The fixed display order of the picker groups."
|
||||||
|
@spec group_order() :: [group()]
|
||||||
|
def group_order, do: @group_order
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns `[{group, [descriptor]}]` in display order, including only groups that
|
||||||
|
have at least one descriptor. Groups with no available fields are omitted so
|
||||||
|
the picker renders no empty header (§1.10).
|
||||||
|
"""
|
||||||
|
@spec visible_groups([t()]) :: [{group(), [t()]}]
|
||||||
|
def visible_groups(descriptors) when is_list(descriptors) do
|
||||||
|
grouped = Enum.group_by(descriptors, & &1.group)
|
||||||
|
|
||||||
|
@group_order
|
||||||
|
|> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end)
|
||||||
|
|> Enum.reject(fn {_group, ds} -> ds == [] end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- static groups --------------------------------------------------------
|
||||||
|
|
||||||
|
defp quick do
|
||||||
|
[
|
||||||
|
%{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count},
|
||||||
|
%{
|
||||||
|
key: :active_former,
|
||||||
|
group: :quick,
|
||||||
|
label: gettext("Active / former"),
|
||||||
|
control: :active_former
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp membership(groups, fee_types) do
|
||||||
|
maybe_group(groups) ++
|
||||||
|
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}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_group([]), do: []
|
||||||
|
|
||||||
|
defp maybe_group(_groups),
|
||||||
|
do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}]
|
||||||
|
|
||||||
|
defp maybe_fee_type([]), do: []
|
||||||
|
|
||||||
|
defp maybe_fee_type(_fee_types),
|
||||||
|
do: [
|
||||||
|
%{
|
||||||
|
key: :fee_type,
|
||||||
|
group: :membership,
|
||||||
|
label: gettext("Fee type"),
|
||||||
|
control: :fee_type_membership
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
defp custom_fields(custom_fields) do
|
||||||
|
custom_fields
|
||||||
|
|> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types))
|
||||||
|
|> Enum.map(fn cf ->
|
||||||
|
%{
|
||||||
|
key: to_string(cf.id),
|
||||||
|
group: :custom_fields,
|
||||||
|
label: cf.name,
|
||||||
|
control: control_for_custom_field(cf.value_type)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp control_for_custom_field(:boolean), do: :boolean
|
||||||
|
defp control_for_custom_field(:date), do: :date_range
|
||||||
|
end
|
||||||
|
|
@ -52,6 +52,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
opts[:show_current_cycle],
|
opts[:show_current_cycle],
|
||||||
today(opts)
|
today(opts)
|
||||||
)
|
)
|
||||||
|
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||||
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -232,6 +233,40 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payment aging filter (period-scoped unpaid-cycle count)
|
||||||
|
#
|
||||||
|
# `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in
|
||||||
|
# the active period; `{:has_unpaid, n}` keeps members with at least n such
|
||||||
|
# cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a
|
||||||
|
# correlated aggregate over `membership_fee_cycles`), so no member set is
|
||||||
|
# loaded into memory. Suspended/paid cycles are excluded by the calculation.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp payment_period(opts) do
|
||||||
|
case opts[:payment_period] do
|
||||||
|
%{from: _, to: _} = period -> period
|
||||||
|
_ -> %{from: nil, to: nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to})
|
||||||
|
when is_integer(n) and n > 0 do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_payment_filter(query, _filter, _period), do: query
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Built-in date filters (join/exit)
|
# Built-in date filters (join/exit)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,11 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
||||||
|
|
||||||
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
|
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
|
||||||
|
|
||||||
|
@type filter :: nil | :fully_paid | {:has_unpaid, 1..3}
|
||||||
|
|
||||||
@payment_period_from_param Constants.payment_period_from_param()
|
@payment_period_from_param Constants.payment_period_from_param()
|
||||||
@payment_period_to_param Constants.payment_period_to_param()
|
@payment_period_to_param Constants.payment_period_to_param()
|
||||||
|
@payment_filter_param Constants.payment_filter_param()
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
The default period: all outstanding cycles, all time (both bounds nil).
|
The default period: all outstanding cycles, all time (both bounds nil).
|
||||||
|
|
@ -62,6 +65,39 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
||||||
|
|
||||||
def to_params(_), do: %{}
|
def to_params(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decodes the payment-count filter param.
|
||||||
|
|
||||||
|
* `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period)
|
||||||
|
* `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}`
|
||||||
|
(at least N unpaid cycles in the period)
|
||||||
|
* anything else → `nil` (no payment-count filter)
|
||||||
|
"""
|
||||||
|
@spec parse_filter(term()) :: filter()
|
||||||
|
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}
|
||||||
|
def parse_filter("unpaid_3"), do: {:has_unpaid, 3}
|
||||||
|
def parse_filter(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Encodes a payment-count filter into URL params. `nil` yields the empty map.
|
||||||
|
"""
|
||||||
|
@spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()}
|
||||||
|
def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"}
|
||||||
|
|
||||||
|
def filter_to_params({:has_unpaid, n}) when n in 1..3,
|
||||||
|
do: %{@payment_filter_param => "unpaid_#{n}"}
|
||||||
|
|
||||||
|
def filter_to_params(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decodes the payment-count filter from a full params map.
|
||||||
|
"""
|
||||||
|
@spec parse_filter_params(map()) :: filter()
|
||||||
|
def parse_filter_params(params) when is_map(params),
|
||||||
|
do: parse_filter(Map.get(params, @payment_filter_param))
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
|
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
|
||||||
(success); a positive count reads "N unpaid" (error).
|
(success); a positive count reads "N unpaid" (error).
|
||||||
|
|
|
||||||
|
|
@ -1297,6 +1297,7 @@ msgid "Exit Date"
|
||||||
msgstr "Austrittsdatum"
|
msgstr "Austrittsdatum"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exit date"
|
msgid "Exit date"
|
||||||
|
|
@ -1470,6 +1471,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
|
||||||
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
|
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type"
|
msgid "Fee type"
|
||||||
|
|
@ -1906,6 +1908,7 @@ msgid "Join confirmation"
|
||||||
msgstr "Beitrittsbestätigung"
|
msgstr "Beitrittsbestätigung"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join date"
|
msgid "Join date"
|
||||||
msgstr "Beitrittsdatum"
|
msgstr "Beitrittsdatum"
|
||||||
|
|
@ -4219,3 +4222,18 @@ msgstr "Keine Adresse"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "%{count} unpaid"
|
msgid "%{count} unpaid"
|
||||||
msgstr "%{count} unbezahlt"
|
msgstr "%{count} unbezahlt"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active / former"
|
||||||
|
msgstr "Aktiv / ehemalig"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Group"
|
||||||
|
msgstr "Gruppen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr "Zahlungen"
|
||||||
|
|
|
||||||
|
|
@ -1298,6 +1298,7 @@ msgid "Exit Date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exit date"
|
msgid "Exit date"
|
||||||
|
|
@ -1471,6 +1472,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type"
|
msgid "Fee type"
|
||||||
|
|
@ -1907,6 +1909,7 @@ msgid "Join confirmation"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join date"
|
msgid "Join date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4219,3 +4222,18 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "%{count} unpaid"
|
msgid "%{count} unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active / former"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -1298,6 +1298,7 @@ msgid "Exit Date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Exit date"
|
msgid "Exit date"
|
||||||
|
|
@ -1471,6 +1472,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Fee type"
|
msgid "Fee type"
|
||||||
|
|
@ -1907,6 +1909,7 @@ msgid "Join confirmation"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Join date"
|
msgid "Join date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2697,6 +2700,7 @@ msgstr ""
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
|
|
@ -4213,3 +4217,23 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "%{count} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active / former"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Group"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,74 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# §2.4 — single-source exit-date state (quick ↔ detail) ----------------
|
||||||
|
#
|
||||||
|
# The active/former quick filter is a shortcut onto the very same
|
||||||
|
# `exit_date` state the detailed control writes: there is no separate
|
||||||
|
# quick-filter field. This property drives interleaved sequences of quick
|
||||||
|
# and detailed changes and asserts that `quick_state/1` is always a pure
|
||||||
|
# function of the single `exit_date` source — i.e. the two views can never
|
||||||
|
# diverge.
|
||||||
|
|
||||||
|
defp quick_choice_gen, do: member_of([:active, :former, :all])
|
||||||
|
|
||||||
|
defp command_gen do
|
||||||
|
one_of([
|
||||||
|
gen(all(s <- quick_choice_gen()), do: {:quick, s}),
|
||||||
|
gen all(
|
||||||
|
mode <- exit_date_mode_gen(),
|
||||||
|
from <- optional_date_gen(),
|
||||||
|
to <- optional_date_gen()
|
||||||
|
) do
|
||||||
|
{:detail, %{mode: mode, from: from, to: to}}
|
||||||
|
end
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_command(filters, {:quick, s}), do: DateFilter.set_quick_state(filters, s)
|
||||||
|
defp apply_command(filters, {:detail, exit_date}), do: Map.put(filters, :exit_date, exit_date)
|
||||||
|
|
||||||
|
defp expected_quick_for(:active_only), do: :active
|
||||||
|
defp expected_quick_for(:inactive_only), do: :former
|
||||||
|
defp expected_quick_for(:all), do: :all
|
||||||
|
defp expected_quick_for(:custom), do: :custom
|
||||||
|
|
||||||
|
property "quick filter is a single-source shortcut onto exit_date, never divergent" do
|
||||||
|
check all(commands <- list_of(command_gen(), max_length: 12)) do
|
||||||
|
final =
|
||||||
|
Enum.reduce(commands, DateFilter.default(), fn command, filters ->
|
||||||
|
next = apply_command(filters, command)
|
||||||
|
|
||||||
|
# Invariant at every step: quick_state is derived solely from the
|
||||||
|
# exit_date mode — one consistent value, no second source.
|
||||||
|
assert DateFilter.quick_state(next) == expected_quick_for(next.exit_date.mode)
|
||||||
|
|
||||||
|
# A quick command resolves to exactly the chosen state and clears bounds.
|
||||||
|
case command do
|
||||||
|
{:quick, s} ->
|
||||||
|
assert DateFilter.quick_state(next) == s
|
||||||
|
assert next.exit_date.from == nil
|
||||||
|
assert next.exit_date.to == nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
next
|
||||||
|
end)
|
||||||
|
|
||||||
|
# join_date is untouched by any exit_date command (no cross-contamination).
|
||||||
|
assert final.join_date == DateFilter.default().join_date
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "set_quick_state ∘ quick_state round-trips for the three quick states" do
|
||||||
|
check all(s <- quick_choice_gen(), exit_date <- exit_date_state_gen()) do
|
||||||
|
filters = Map.put(DateFilter.default(), :exit_date, exit_date)
|
||||||
|
assert filters |> DateFilter.set_quick_state(s) |> DateFilter.quick_state() == s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
property "encoding then decoding built-in date filter state is identity" do
|
property "encoding then decoding built-in date filter state is identity" do
|
||||||
check all(
|
check all(
|
||||||
join_date <- join_date_state_gen(),
|
join_date <- join_date_state_gen(),
|
||||||
|
|
|
||||||
64
test/mv_web/member_live/index_active_former_test.exs
Normal file
64
test/mv_web/member_live/index_active_former_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexActiveFormerTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.7 — the active/former quick filter is a three-state shortcut onto the
|
||||||
|
shared `exit_date` state:
|
||||||
|
|
||||||
|
* "Former" → members with a past exit date (`ed_mode=inactive_only`)
|
||||||
|
* "Active" → members with no or a future exit date (default / `active_only`)
|
||||||
|
* "All" → every member (`ed_mode=all`)
|
||||||
|
|
||||||
|
These assert the *result semantics* through the live overview and its URL
|
||||||
|
contract, which the quick-filter UI drives; they are independent of the
|
||||||
|
particular chip control and survive the builder rework.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
|
active = Fixtures.member_fixture(%{first_name: "Ava", last_name: "Active", exit_date: nil})
|
||||||
|
|
||||||
|
future =
|
||||||
|
Fixtures.member_fixture(%{
|
||||||
|
first_name: "Finn",
|
||||||
|
last_name: "Future",
|
||||||
|
exit_date: Date.add(Date.utc_today(), 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
former =
|
||||||
|
Fixtures.member_fixture(%{
|
||||||
|
first_name: " Former",
|
||||||
|
last_name: "Past",
|
||||||
|
exit_date: Date.add(Date.utc_today(), -30)
|
||||||
|
})
|
||||||
|
|
||||||
|
%{conn: conn, active: active, future: future, former: former}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Former shows only members with a past exit date", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, "/members?ed_mode=inactive_only")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Active shows members with no or a future exit date", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, "/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
assert has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "All shows every member regardless of exit date", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, "/members?ed_mode=all")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
assert has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
82
test/mv_web/member_live/index_field_picker_test.exs
Normal file
82
test/mv_web/member_live/index_field_picker_test.exs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFieldPickerTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.10 / §3.5 — the FilterDescriptor catalog behind the field picker.
|
||||||
|
|
||||||
|
Fields are grouped Quick / Membership / Custom fields; a group with no
|
||||||
|
available fields is omitted (renders no header). The catalog is derived
|
||||||
|
purely from the field context and is serializable (the persistence basis for
|
||||||
|
saved views, #549).
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.FilterDescriptor
|
||||||
|
|
||||||
|
defp group_keys(descriptors, group) do
|
||||||
|
descriptors
|
||||||
|
|> Enum.filter(&(&1.group == group))
|
||||||
|
|> Enum.map(& &1.key)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "groups are ordered Quick / Membership / Custom fields" do
|
||||||
|
context = %{
|
||||||
|
groups: [%{id: Ecto.UUID.generate(), name: "Board"}],
|
||||||
|
fee_types: [%{id: Ecto.UUID.generate(), name: "Standard"}],
|
||||||
|
custom_fields: [%{id: Ecto.UUID.generate(), name: "Newsletter", value_type: :boolean}]
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptors = FilterDescriptor.all(context)
|
||||||
|
|
||||||
|
assert Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) ==
|
||||||
|
[:quick, :membership, :custom_fields]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "quick and membership fields are present with type-aware controls" do
|
||||||
|
descriptors = FilterDescriptor.all(%{groups: [%{id: Ecto.UUID.generate(), name: "G"}]})
|
||||||
|
|
||||||
|
assert group_keys(descriptors, :quick) == [:payment, :active_former]
|
||||||
|
assert :group in group_keys(descriptors, :membership)
|
||||||
|
assert :join_date in group_keys(descriptors, :membership)
|
||||||
|
assert :exit_date in group_keys(descriptors, :membership)
|
||||||
|
|
||||||
|
payment = Enum.find(descriptors, &(&1.key == :payment))
|
||||||
|
assert payment.control == :payment_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty custom-fields group is omitted (no header)" do
|
||||||
|
descriptors = FilterDescriptor.all(%{custom_fields: []})
|
||||||
|
|
||||||
|
groups = Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0))
|
||||||
|
refute :custom_fields in groups
|
||||||
|
end
|
||||||
|
|
||||||
|
test "membership omits Group/Fee type when the club has none" do
|
||||||
|
descriptors = FilterDescriptor.all(%{groups: [], fee_types: []})
|
||||||
|
|
||||||
|
membership_keys = group_keys(descriptors, :membership)
|
||||||
|
refute :group in membership_keys
|
||||||
|
refute :fee_type in membership_keys
|
||||||
|
# Join/exit date are always available.
|
||||||
|
assert :join_date in membership_keys
|
||||||
|
assert :exit_date in membership_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
test "one custom-fields descriptor per filterable field; non-filterable types dropped" do
|
||||||
|
date_id = Ecto.UUID.generate()
|
||||||
|
bool_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
context = %{
|
||||||
|
custom_fields: [
|
||||||
|
%{id: date_id, name: "Birthday", value_type: :date},
|
||||||
|
%{id: bool_id, name: "Consent", value_type: :boolean},
|
||||||
|
%{id: Ecto.UUID.generate(), name: "Phone", value_type: :string}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
custom = Enum.filter(FilterDescriptor.all(context), &(&1.group == :custom_fields))
|
||||||
|
|
||||||
|
assert Enum.map(custom, & &1.key) == [to_string(date_id), to_string(bool_id)]
|
||||||
|
# Keys are plain strings — serializable for #549.
|
||||||
|
assert Enum.all?(custom, &is_binary(&1.key))
|
||||||
|
assert Enum.find(custom, &(&1.key == to_string(date_id))).control == :date_range
|
||||||
|
end
|
||||||
|
end
|
||||||
77
test/mv_web/member_live/index_payment_filter_test.exs
Normal file
77
test/mv_web/member_live/index_payment_filter_test.exs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.16 / §2.2 / §3.3 — the payment-count filter in OverviewQuery.
|
||||||
|
|
||||||
|
`fully_paid` selects members with 0 unpaid cycles in the active period;
|
||||||
|
`{:has_unpaid, N}` selects members with at least N — consistent with the
|
||||||
|
aging count (§2.5). All filtering resolves DB-side via the
|
||||||
|
`unpaid_cycle_count` calculation.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
@period %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
|
# Wipe the seeded/other members so id sets are deterministic.
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
|
||||||
|
%{actor: actor, ft: ft}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_with_unpaid(n, actor, ft) do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
Enum.each(1..max(n, 0)//1, fn i ->
|
||||||
|
create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor)
|
||||||
|
end)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(filter, actor) do
|
||||||
|
%{payment_filter: filter, payment_period: @period}
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fully_paid selects members with zero in-period unpaid cycles", %{actor: actor, ft: ft} do
|
||||||
|
paid = member_with_unpaid(0, actor, ft)
|
||||||
|
create_cycle(paid, ft, %{cycle_start: ~D[2024-05-01], status: :paid}, actor)
|
||||||
|
one = member_with_unpaid(1, actor, ft)
|
||||||
|
|
||||||
|
result = ids(:fully_paid, actor)
|
||||||
|
assert MapSet.member?(result, paid.id)
|
||||||
|
refute MapSet.member?(result, one.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has-unpaid thresholds select members with count >= N", %{actor: actor, ft: ft} do
|
||||||
|
zero = member_with_unpaid(0, actor, ft)
|
||||||
|
one = member_with_unpaid(1, actor, ft)
|
||||||
|
two = member_with_unpaid(2, actor, ft)
|
||||||
|
three = member_with_unpaid(3, actor, ft)
|
||||||
|
|
||||||
|
assert ids({:has_unpaid, 1}, actor) == MapSet.new([one.id, two.id, three.id])
|
||||||
|
assert ids({:has_unpaid, 2}, actor) == MapSet.new([two.id, three.id])
|
||||||
|
assert ids({:has_unpaid, 3}, actor) == MapSet.new([three.id])
|
||||||
|
refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor)
|
||||||
|
|
||||||
|
refute MapSet.member?(ids({:has_unpaid, 1}, actor), member.id)
|
||||||
|
assert MapSet.member?(ids(:fully_paid, actor), member.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,6 +5,7 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
|
||||||
backs the badge tooltip (unpaid cycles for the period; suspended shown apart).
|
backs the badge tooltip (unpaid cycles for the period; suspended shown apart).
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
|
@ -39,6 +40,58 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "payment-count filter codec" do
|
||||||
|
test "round-trips fully_paid and has-unpaid thresholds" do
|
||||||
|
for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do
|
||||||
|
params = PaymentAging.filter_to_params(state)
|
||||||
|
assert PaymentAging.parse_filter_params(params) == state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil filter serializes to no params and unknown values parse to nil" do
|
||||||
|
assert PaymentAging.filter_to_params(nil) == %{}
|
||||||
|
assert PaymentAging.parse_filter_params(%{}) == nil
|
||||||
|
assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "serializes to stable string tokens" do
|
||||||
|
assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"}
|
||||||
|
assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "payment URL round-trip (§2.1, payment keys)" do
|
||||||
|
@dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]]
|
||||||
|
@filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}]
|
||||||
|
|
||||||
|
property "decode∘encode is canonical and idempotent for period + payment filter" do
|
||||||
|
check all(
|
||||||
|
from <- StreamData.member_of(@dates),
|
||||||
|
to <- StreamData.member_of(@dates),
|
||||||
|
filter <- StreamData.member_of(@filters)
|
||||||
|
) do
|
||||||
|
period = %{from: from, to: to}
|
||||||
|
|
||||||
|
params =
|
||||||
|
period
|
||||||
|
|> PaymentAging.to_params()
|
||||||
|
|> Map.merge(PaymentAging.filter_to_params(filter))
|
||||||
|
|
||||||
|
assert PaymentAging.parse_period(params) == period
|
||||||
|
assert PaymentAging.parse_filter_params(params) == filter
|
||||||
|
|
||||||
|
# Idempotence: re-encoding the decoded state yields the same params.
|
||||||
|
reencoded =
|
||||||
|
params
|
||||||
|
|> PaymentAging.parse_period()
|
||||||
|
|> PaymentAging.to_params()
|
||||||
|
|> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params)))
|
||||||
|
|
||||||
|
assert reencoded == params
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "badge/1" do
|
describe "badge/1" do
|
||||||
test "0 renders as Paid (success)" do
|
test "0 renders as Paid (success)" do
|
||||||
badge = PaymentAging.badge(0)
|
badge = PaymentAging.badge(0)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue