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
|
||||
|
||||
@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 """
|
||||
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],
|
||||
today(opts)
|
||||
)
|
||||
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|
||||
end
|
||||
|
||||
|
|
@ -232,6 +233,40 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
)
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,8 +28,11 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
|
||||
@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_to_param Constants.payment_period_to_param()
|
||||
@payment_filter_param Constants.payment_filter_param()
|
||||
|
||||
@doc """
|
||||
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: %{}
|
||||
|
||||
@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 """
|
||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
|
||||
(success); a positive count reads "N unpaid" (error).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue