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:
parent
7d1a71b1fd
commit
c547ca19af
35 changed files with 2817 additions and 3149 deletions
|
|
@ -100,7 +100,14 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
|||
`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()
|
||||
@spec set_quick_state(map(), :active | :former | :all) :: %{
|
||||
:exit_date => %{
|
||||
from: nil,
|
||||
mode: :active_only | :inactive_only | :all,
|
||||
to: nil
|
||||
},
|
||||
optional(any()) => any()
|
||||
}
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
|
|||
query: assigns[:query] || nil,
|
||||
sort_field: sort_field(assigns[:sort_field]),
|
||||
sort_order: sort_order(assigns[:sort_order]),
|
||||
show_current_cycle: assigns[:show_current_cycle] || false,
|
||||
cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
|
||||
# Decoupled from the removed overview current/last cycle toggle (§3.3): the
|
||||
# export's fee-status column always uses the current cycle as its basis and
|
||||
# applies no cycle-status row filter (the overview now filters payment via
|
||||
# the period-scoped aging model, which the export does not mirror).
|
||||
show_current_cycle: true,
|
||||
cycle_status_filter: nil,
|
||||
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
|
||||
}
|
||||
end
|
||||
|
|
@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
|
|||
def sort_order(:desc), do: "desc"
|
||||
def sort_order(o) when is_binary(o), do: o
|
||||
|
||||
defp cycle_status_filter(nil), do: nil
|
||||
defp cycle_status_filter(:paid), do: "paid"
|
||||
defp cycle_status_filter(:unpaid), do: "unpaid"
|
||||
defp cycle_status_filter(_), do: nil
|
||||
|
||||
defp build_member_fields_list(ordered_db, member_fields_visible) do
|
||||
member_fields_visible = member_fields_visible || []
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do
|
|||
def group_for(%{group: group}), do: group
|
||||
|
||||
@doc "The fixed display order of the picker groups."
|
||||
@spec group_order() :: [group()]
|
||||
@spec group_order() :: [group(), ...]
|
||||
def group_order, do: @group_order
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -47,17 +47,10 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
)
|
||||
|> apply_date_filters(opts[:date_filters])
|
||||
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||
|> apply_cycle_status_filter(
|
||||
opts[:cycle_status_filter],
|
||||
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
|
||||
|
||||
defp today(opts), do: opts[:today] || Date.utc_today()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -184,55 +177,6 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
|
||||
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle-status filter (paid/unpaid, current or last-completed cycle)
|
||||
#
|
||||
# Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
|
||||
# matches only when its selected-cycle status equals the requested status;
|
||||
# members with no matching cycle (nil aggregate) are excluded — exactly as the
|
||||
# previous in-memory classifier behaved.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp apply_cycle_status_filter(query, status, _show_current, _today)
|
||||
when status not in [:paid, :unpaid],
|
||||
do: query
|
||||
|
||||
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
|
||||
# Current cycle: contains today; when several would, the one with the latest
|
||||
# cycle_start wins. Keep members whose winning current cycle has `status`.
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(
|
||||
exists(
|
||||
membership_fee_cycles,
|
||||
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
|
||||
not exists(
|
||||
member.membership_fee_cycles,
|
||||
cycle_start <= ^today and cycle_end >= ^today and
|
||||
cycle_start > parent(cycle_start)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_cycle_status_filter(query, status, _show_current, today) do
|
||||
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(
|
||||
exists(
|
||||
membership_fee_cycles,
|
||||
cycle_end < ^today and status == ^status and
|
||||
not exists(
|
||||
member.membership_fee_cycles,
|
||||
cycle_end < ^today and cycle_start > parent(cycle_start)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment aging filter (period-scoped unpaid-cycle count)
|
||||
#
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Constants
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
|
||||
|
|
@ -99,40 +100,111 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
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).
|
||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
|
||||
(success/green); a positive count reads "N offen" (error/red). Reuses the
|
||||
established cycle-status color and hero-icon codes so the overview badge reads
|
||||
consistently with the member show page.
|
||||
"""
|
||||
@spec badge(non_neg_integer()) :: %{
|
||||
variant: atom(),
|
||||
color_class: String.t(),
|
||||
icon: String.t(),
|
||||
label: String.t(),
|
||||
count: non_neg_integer()
|
||||
}
|
||||
def badge(0), do: %{variant: :success, label: gettext("Paid"), count: 0}
|
||||
def badge(0) do
|
||||
%{
|
||||
variant: :success,
|
||||
color_class: cycle_color_class(:paid),
|
||||
icon: MembershipFeeHelpers.status_icon(:paid),
|
||||
label: gettext("All paid"),
|
||||
count: 0
|
||||
}
|
||||
end
|
||||
|
||||
def badge(count) when is_integer(count) and count > 0 do
|
||||
%{
|
||||
variant: MembershipFeeHelpers.status_variant(:unpaid),
|
||||
label: gettext("%{count} unpaid", count: count),
|
||||
color_class: cycle_color_class(:unpaid),
|
||||
icon: MembershipFeeHelpers.status_icon(:unpaid),
|
||||
label: gettext("%{count} open", count: count),
|
||||
count: count
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Splits a member's loaded cycles into the open (unpaid) and suspended cycles
|
||||
whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid
|
||||
cycles and cycles outside the period are dropped. Used to render the badge
|
||||
tooltip. Expects `membership_fee_cycles` to be loaded on the member.
|
||||
A member's open cycles for the badge tooltip: the unpaid and suspended cycles
|
||||
whose `cycle_end` falls inside `period`, merged into a single list sorted
|
||||
chronologically by `cycle_start`. Paid cycles and cycles outside the period
|
||||
are dropped. Expects `membership_fee_cycles` to be loaded on the member.
|
||||
"""
|
||||
@spec open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]}
|
||||
@spec open_cycles(map(), period()) :: [map()]
|
||||
def open_cycles(member, %{from: from, to: to}) do
|
||||
cycles = in_period_cycles(member, from, to)
|
||||
|
||||
%{
|
||||
unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(),
|
||||
suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end()
|
||||
}
|
||||
member
|
||||
|> in_period_cycles(from, to)
|
||||
|> Enum.filter(&(&1.status in [:unpaid, :suspended]))
|
||||
|> Enum.sort_by(& &1.cycle_start, Date)
|
||||
end
|
||||
|
||||
@tooltip_slots 5
|
||||
|
||||
@doc """
|
||||
Partitions open cycles into the tooltip's fixed five-slot budget: at most
|
||||
five badges total. Five or fewer cycles show in full with no overflow. More
|
||||
than five collapse to the first four chronological cycles plus a single
|
||||
overflow count (total − 4), so the tooltip renders four cycle badges and one
|
||||
grey overflow badge — five items.
|
||||
"""
|
||||
@spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()}
|
||||
def tooltip_cycles(cycles) when is_list(cycles) do
|
||||
if length(cycles) > @tooltip_slots do
|
||||
{Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)}
|
||||
else
|
||||
{cycles, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Static daisyUI badge color token for a cycle status, matching the codes used
|
||||
on the member show page. Literal strings so Tailwind's content scanner keeps
|
||||
them in the bundle (a runtime `badge-\#{status}` would be tree-shaken).
|
||||
"""
|
||||
@spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t()
|
||||
def cycle_color_class(:paid), do: "badge-success"
|
||||
def cycle_color_class(:unpaid), do: "badge-error"
|
||||
def cycle_color_class(:suspended), do: "badge-warning"
|
||||
|
||||
@doc """
|
||||
Hero-icon name for a cycle status (reuses the established status icons).
|
||||
"""
|
||||
@spec cycle_icon(:paid | :unpaid | :suspended) :: String.t()
|
||||
def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status)
|
||||
|
||||
@doc """
|
||||
Short, human-readable period label for a cycle, derived from its `cycle_start`
|
||||
and the fee-type interval:
|
||||
|
||||
* yearly → `"2025"`
|
||||
* quarterly → `"Q1 2026"`
|
||||
* half-yearly → `"H1 2026"`
|
||||
* monthly → `"März 2026"`
|
||||
|
||||
Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not
|
||||
loaded/known.
|
||||
"""
|
||||
@spec short_period(map()) :: String.t()
|
||||
def short_period(%{cycle_start: %Date{} = start} = cycle) do
|
||||
case cycle_interval(cycle) do
|
||||
:yearly -> Integer.to_string(start.year)
|
||||
:quarterly -> "Q#{quarter(start.month)} #{start.year}"
|
||||
:half_yearly -> "H#{half(start.month)} #{start.year}"
|
||||
:monthly -> "#{month_name(start.month)} #{start.year}"
|
||||
_ -> cycle_date_range(cycle)
|
||||
end
|
||||
end
|
||||
|
||||
def short_period(cycle), do: cycle_date_range(cycle)
|
||||
|
||||
defp in_period_cycles(member, from, to) do
|
||||
case Map.get(member, :membership_fee_cycles) do
|
||||
cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to))
|
||||
|
|
@ -147,7 +219,35 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
|
||||
defp in_period?(_, _, _), do: false
|
||||
|
||||
defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date)
|
||||
defp cycle_interval(%{membership_fee_type: %{interval: interval}})
|
||||
when interval in [:monthly, :quarterly, :half_yearly, :yearly],
|
||||
do: interval
|
||||
|
||||
defp cycle_interval(_), do: nil
|
||||
|
||||
defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}),
|
||||
do: "#{DateFormatter.format_date(s)}–#{DateFormatter.format_date(e)}"
|
||||
|
||||
defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s)
|
||||
defp cycle_date_range(_), do: ""
|
||||
|
||||
defp quarter(month), do: div(month - 1, 3) + 1
|
||||
|
||||
defp half(month) when month <= 6, do: 1
|
||||
defp half(_month), do: 2
|
||||
|
||||
defp month_name(1), do: gettext("January")
|
||||
defp month_name(2), do: gettext("February")
|
||||
defp month_name(3), do: gettext("March")
|
||||
defp month_name(4), do: gettext("April")
|
||||
defp month_name(5), do: gettext("May")
|
||||
defp month_name(6), do: gettext("June")
|
||||
defp month_name(7), do: gettext("July")
|
||||
defp month_name(8), do: gettext("August")
|
||||
defp month_name(9), do: gettext("September")
|
||||
defp month_name(10), do: gettext("October")
|
||||
defp month_name(11), do: gettext("November")
|
||||
defp month_name(12), do: gettext("December")
|
||||
|
||||
defp maybe_put_date(params, _key, nil), do: params
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue