feat(member-filter): add date filter sections with active-count badge and reset support
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Moritz 2026-05-20 16:32:29 +02:00
parent e3295ab4b5
commit d6671daf1a
7 changed files with 834 additions and 18 deletions

View file

@ -23,6 +23,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
- `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:date_custom_fields` - List of date-typed custom fields rendered in the
"Custom date fields" section (each with `:id`, `:name`, `:value_type`).
- `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`):
built-in `:join_date` / `:exit_date` bounds and mode, plus optional
UUID-keyed custom date field bound entries.
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
@ -31,13 +36,18 @@ defmodule MvWeb.Components.MemberFilterComponent do
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
- Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in)
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
- Sends `{:date_filters_changed, new_filters}` to parent when any date
filter input changes (built-in date bounds, exit_date mode, or custom
date field bounds).
"""
use MvWeb, :live_component
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.FilterParams
@group_filter_prefix Mv.Constants.group_filter_prefix()
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
@impl true
def mount(socket) do
@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:groups, assigns[:groups] || [])
|> assign(:group_filters, assigns[:group_filters] || %{})
|> assign(:group_filter_prefix, @group_filter_prefix)
|> assign(:fee_types, assigns[:fee_types] || [])
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign_group_assigns(assigns)
|> assign_fee_type_assigns(assigns)
|> assign_boolean_assigns(assigns)
|> assign_date_assigns(assigns)
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
defp assign_group_assigns(socket, assigns) do
socket
|> assign(:groups, assigns[:groups] || [])
|> assign(:group_filters, assigns[:group_filters] || %{})
|> assign(:group_filter_prefix, @group_filter_prefix)
end
defp assign_fee_type_assigns(socket, assigns) do
socket
|> assign(:fee_types, assigns[:fee_types] || [])
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
end
defp assign_boolean_assigns(socket, assigns) do
socket
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
end
defp assign_date_assigns(socket, assigns) do
socket
|> assign(:date_custom_fields, assigns[:date_custom_fields] || [])
|> assign(:date_filters, assigns[:date_filters] || DateFilter.default())
|> assign(:custom_date_filter_prefix, @custom_date_filter_prefix)
end
@impl true
def render(assigns) do
~H"""
@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
"gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 ||
map_size(@fee_type_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) &&
active_boolean_filters_count(@boolean_filters) > 0 ||
date_filters_active?(@date_filters)) &&
"btn-active"
]}
phx-click="toggle_dropdown"
@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
@fee_types,
@fee_type_filters,
@boolean_custom_fields,
@boolean_filters
@boolean_filters,
@date_filters
)}
</span>
<.badge
@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
</.badge>
<.badge
:if={
(@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) &&
(@cycle_status_filter || map_size(@group_filters) > 0 ||
map_size(@fee_type_filters) > 0 ||
date_filters_active?(@date_filters)) &&
active_boolean_filters_count(@boolean_filters) == 0
}
variant="primary"
@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do
</div>
</div>
<!-- Dates Group (built-in fields: exit_date with mode selector, join_date range) -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Dates")}
</div>
<fieldset class="border-0 p-0 m-0 min-w-0 mb-3">
<legend class="text-sm font-medium mb-1">
{gettext("Exit date")}
</legend>
<div class="join w-full">
<label
class={"#{exit_mode_label_class(@date_filters, :active_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-active-only"
>
<input
type="radio"
id="ed-mode-active-only"
name="ed_mode"
value="active_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :active_only}
/>
<span class="text-xs">{gettext("Active only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :all)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-all"
>
<input
type="radio"
id="ed-mode-all"
name="ed_mode"
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :all}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :inactive_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-inactive-only"
>
<input
type="radio"
id="ed-mode-inactive-only"
name="ed_mode"
value="inactive_only"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :inactive_only}
/>
<span class="text-xs">{gettext("Inactive only")}</span>
</label>
<label
class={"#{exit_mode_label_class(@date_filters, :custom)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for="ed-mode-custom"
>
<input
type="radio"
id="ed-mode-custom"
name="ed_mode"
value="custom"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={exit_mode(@date_filters) == :custom}
/>
<span class="text-xs">{gettext("Range")}</span>
</label>
</div>
<div
:if={exit_mode(@date_filters) == :custom}
class="mt-2 flex gap-3 items-end flex-wrap"
>
<.input
type="date"
id="ed-from"
name="ed_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date from")}
value={date_value_for_input(@date_filters, :exit_date, :from)}
/>
<.input
type="date"
id="ed-to"
name="ed_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Exit date to")}
value={date_value_for_input(@date_filters, :exit_date, :to)}
/>
</div>
</fieldset>
<fieldset class="border-0 p-0 m-0 min-w-0">
<legend class="text-sm font-medium mb-1">
{gettext("Join date")}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id="jd-from"
name="jd_from"
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("Join date from")}
value={date_value_for_input(@date_filters, :join_date, :from)}
/>
<.input
type="date"
id="jd-to"
name="jd_to"
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("Join date to")}
value={date_value_for_input(@date_filters, :join_date, :to)}
/>
</div>
</fieldset>
</div>
<!-- Custom Date Fields Group (in-memory filter; one row per :date custom field) -->
<div :if={length(@date_custom_fields) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom date fields")}
</div>
<div class={
if length(@date_custom_fields) > 5, do: "max-h-60 overflow-y-auto pr-2", else: ""
}>
<fieldset
:for={field <- @date_custom_fields}
class="border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0 py-2"
>
<legend class="text-sm font-medium mb-1">
{field.name}
</legend>
<div class="flex gap-3 items-end flex-wrap">
<.input
type="date"
id={"cdf-#{field.id}-from"}
name={"#{@custom_date_filter_prefix}#{field.id}_from"}
label={gettext("From")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} from", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :from)}
/>
<.input
type="date"
id={"cdf-#{field.id}-to"}
name={"#{@custom_date_filter_prefix}#{field.id}_to"}
label={gettext("To")}
class="input input-sm input-bordered"
aria-label={gettext("%{field} to", field: field.name)}
value={custom_date_value_for_input(@date_filters, field.id, :to)}
/>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -452,11 +646,13 @@ defmodule MvWeb.Components.MemberFilterComponent do
)
custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields)
dispatch_payment_filter_change(socket, payment_filter)
dispatch_group_filter_changes(socket, group_filters_parsed)
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
dispatch_date_filters_change(socket, new_date_filters)
{:noreply, socket}
end
@ -540,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do
end)
end
defp dispatch_date_filters_change(socket, new_date_filters) do
if new_date_filters != socket.assigns.date_filters do
send(self(), {:date_filters_changed, new_date_filters})
end
end
# Get display label for button
defp button_label(
cycle_status_filter,
@ -548,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
fee_types,
fee_type_filters,
boolean_custom_fields,
boolean_filters
boolean_filters,
date_filters
) do
active_count =
count_active_filter_categories(
cycle_status_filter,
group_filters,
fee_type_filters,
boolean_filters
boolean_filters,
date_filters
)
if active_count >= 2 do
@ -576,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
map_size(boolean_filters) > 0 ->
boolean_filter_label(boolean_custom_fields, boolean_filters)
date_filters_active?(date_filters) ->
gettext("Dates")
true ->
gettext("Apply filters")
end
@ -586,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
cycle_status_filter,
group_filters,
fee_type_filters,
boolean_filters
boolean_filters,
date_filters
) do
[
cycle_status_filter,
map_size(group_filters) > 0,
map_size(fee_type_filters) > 0,
map_size(boolean_filters) > 0
map_size(boolean_filters) > 0,
date_filters_active?(date_filters)
]
|> Enum.count(& &1)
end
# Date filter is "active" when its state differs from the default — i.e. the
# user selected something other than active-only with no custom date bounds.
defp date_filters_active?(date_filters) when is_map(date_filters) do
date_filters != DateFilter.default()
end
defp date_filters_active?(_), do: false
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
do: gettext("All")
@ -765,4 +982,35 @@ defmodule MvWeb.Components.MemberFilterComponent do
"#{base_classes} btn-outline"
end
end
# --- Date filter helpers ----------------------------------------------
defp exit_mode(%{exit_date: %{mode: mode}}), do: mode
defp exit_mode(_), do: :active_only
defp exit_mode_label_class(date_filters, expected) do
base_classes = "join-item btn btn-sm"
if exit_mode(date_filters) == expected do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
end
defp date_value_for_input(date_filters, field, bound) do
case date_filters do
%{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d)
_ -> ""
end
end
defp custom_date_value_for_input(date_filters, field_id, bound) do
key = to_string(field_id)
case Map.get(date_filters, key) do
%{^bound => %Date{} = d} -> Date.to_iso8601(d)
_ -> ""
end
end
end

View file

@ -54,6 +54,8 @@
fee_type_filters={@fee_type_filters}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
member_count={length(@members)}
/>
<.tooltip