This commit is contained in:
parent
3322efcdf6
commit
5fd7c0e7f6
13 changed files with 583 additions and 258 deletions
|
|
@ -16,6 +16,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
|
||||
## Props
|
||||
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
|
||||
- `:groups` - List of groups (for per-group filter rows)
|
||||
- `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group)
|
||||
- `:boolean_custom_fields` - List of boolean custom fields to display
|
||||
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
|
||||
- `:id` - Component ID (required)
|
||||
|
|
@ -23,6 +25,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
|
||||
## Events
|
||||
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
|
||||
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
|
||||
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
|
@ -38,6 +41,8 @@ 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(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|
||||
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
|
@ -60,7 +65,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
tabindex="0"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
(@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||
active_boolean_filters_count(@boolean_filters) > 0) &&
|
||||
"btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
|
|
@ -70,7 +77,13 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
>
|
||||
<.icon name="hero-funnel" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">
|
||||
{button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
|
||||
{button_label(
|
||||
@cycle_status_filter,
|
||||
@groups,
|
||||
@group_filters,
|
||||
@boolean_custom_fields,
|
||||
@boolean_filters
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
:if={active_boolean_filters_count(@boolean_filters) > 0}
|
||||
|
|
@ -79,7 +92,10 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
{active_boolean_filters_count(@boolean_filters)}
|
||||
</span>
|
||||
<span
|
||||
:if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
|
||||
:if={
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0) &&
|
||||
active_boolean_filters_count(@boolean_filters) == 0
|
||||
}
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
{@member_count}
|
||||
|
|
@ -103,7 +119,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
role="dialog"
|
||||
aria-label={gettext("Member filter")}
|
||||
>
|
||||
<form phx-change="update_filters" phx-target={@myself}>
|
||||
<form phx-change="update_filters" phx-target={@myself} data-testid="member-filter-form">
|
||||
<!-- Payment Filter Group -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
|
|
@ -162,6 +178,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Groups: one row per group with All / Yes / No (like Custom Fields) -->
|
||||
<div :if={length(@groups) > 0} class="mb-4">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
{gettext("Groups")}
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto pr-2">
|
||||
<fieldset
|
||||
:for={group <- @groups}
|
||||
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
|
||||
>
|
||||
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
||||
{group.name}
|
||||
</legend>
|
||||
<div class="join col-start-2">
|
||||
<label
|
||||
class={"#{group_filter_label_class(@group_filters, group.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for={"group-filter-#{group.id}-all"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"group-filter-#{group.id}-all"}
|
||||
name={"group_#{group.id}"}
|
||||
value="all"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={Map.get(@group_filters, to_string(group.id)) == nil}
|
||||
/>
|
||||
<span class="text-xs">{gettext("All")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{group_filter_label_class(@group_filters, group.id, :in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for={"group-filter-#{group.id}-in"}
|
||||
aria-label={gettext("Yes")}
|
||||
title={gettext("Yes")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"group-filter-#{group.id}-in"}
|
||||
name={"group_#{group.id}"}
|
||||
value="in"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={Map.get(@group_filters, to_string(group.id)) == :in}
|
||||
/>
|
||||
<.icon name="hero-check-circle" class="h-5 w-5" />
|
||||
<span class="text-xs">{gettext("Yes")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{group_filter_label_class(@group_filters, group.id, :not_in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for={"group-filter-#{group.id}-not-in"}
|
||||
aria-label={gettext("No")}
|
||||
title={gettext("No")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"group-filter-#{group.id}-not-in"}
|
||||
name={"group_#{group.id}"}
|
||||
value="not_in"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={Map.get(@group_filters, to_string(group.id)) == :not_in}
|
||||
/>
|
||||
<.icon name="hero-x-circle" class="h-5 w-5" />
|
||||
<span class="text-xs">{gettext("No")}</span>
|
||||
</label>
|
||||
</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">
|
||||
|
|
@ -274,6 +357,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
_ -> nil
|
||||
end
|
||||
|
||||
# Parse per-group filters (params keys "group_<uuid>" => "all"|"in"|"not_in")
|
||||
group_filters_parsed =
|
||||
params
|
||||
|> Enum.filter(fn {key, _} -> String.starts_with?(key, "group_") end)
|
||||
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
|
||||
group_id_str = String.slice(key, 6, String.length(key) - 6)
|
||||
filter_value = parse_group_filter_value(value_str)
|
||||
Map.put(acc, group_id_str, filter_value)
|
||||
end)
|
||||
|
||||
# Parse boolean custom field filters (including nil values for "all")
|
||||
custom_boolean_filters_parsed =
|
||||
params
|
||||
|
|
@ -288,6 +381,21 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
send(self(), {:payment_filter_changed, payment_filter})
|
||||
end
|
||||
|
||||
# Update group filters - send event for each changed group
|
||||
current_group_filters = socket.assigns.group_filters
|
||||
all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id)))
|
||||
|
||||
Enum.each(group_filters_parsed, fn {group_id_str, new_value} ->
|
||||
in_set = MapSet.member?(all_group_ids, group_id_str)
|
||||
current_value = Map.get(current_group_filters, group_id_str)
|
||||
normalized_new = if new_value == nil, do: nil, else: new_value
|
||||
should_send = in_set and current_value != normalized_new
|
||||
|
||||
if should_send do
|
||||
send(self(), {:group_filter_changed, group_id_str, normalized_new})
|
||||
end
|
||||
end)
|
||||
|
||||
# Update boolean filters - send events for each changed filter
|
||||
current_filters = socket.assigns.boolean_filters
|
||||
|
||||
|
|
@ -310,7 +418,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
def handle_event("reset_filters", _params, socket) do
|
||||
# Send single message to reset all filters at once (performance optimization)
|
||||
# This avoids N×2 load_members() calls when resetting multiple filters
|
||||
send(self(), {:reset_all_filters, nil, %{}})
|
||||
send(self(), {:reset_all_filters, nil, %{}, %{}})
|
||||
|
||||
# Close dropdown after reset
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
|
|
@ -322,17 +430,49 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
defp parse_tri_state("all"), do: nil
|
||||
defp parse_tri_state(_), do: nil
|
||||
|
||||
defp parse_group_filter_value("in"), do: :in
|
||||
defp parse_group_filter_value("not_in"), do: :not_in
|
||||
defp parse_group_filter_value(_), do: nil
|
||||
|
||||
# Get display label for button
|
||||
defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
|
||||
# If payment filter is active, show payment filter label
|
||||
if cycle_status_filter do
|
||||
payment_filter_label(cycle_status_filter)
|
||||
else
|
||||
# Otherwise show boolean filter labels
|
||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||
defp button_label(
|
||||
cycle_status_filter,
|
||||
groups,
|
||||
group_filters,
|
||||
boolean_custom_fields,
|
||||
boolean_filters
|
||||
) do
|
||||
cond do
|
||||
cycle_status_filter ->
|
||||
payment_filter_label(cycle_status_filter)
|
||||
|
||||
map_size(group_filters) > 0 ->
|
||||
group_filters_label(groups, group_filters)
|
||||
|
||||
map_size(boolean_filters) > 0 ->
|
||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||
|
||||
true ->
|
||||
gettext("All")
|
||||
end
|
||||
end
|
||||
|
||||
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
|
||||
do: gettext("All")
|
||||
|
||||
defp group_filters_label(groups, group_filters) do
|
||||
names =
|
||||
group_filters
|
||||
|> Enum.map(fn {group_id_str, _} ->
|
||||
Enum.find(groups, fn g -> to_string(g.id) == group_id_str end)
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
label = Enum.join(names, ", ")
|
||||
truncate_label(label, 30)
|
||||
end
|
||||
|
||||
# Get payment filter label
|
||||
defp payment_filter_label(nil), do: gettext("All")
|
||||
defp payment_filter_label(:paid), do: gettext("Paid")
|
||||
|
|
@ -406,6 +546,39 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
end
|
||||
end
|
||||
|
||||
# Get CSS classes for per-group filter label based on current state
|
||||
defp group_filter_label_class(group_filters, group_id, expected_value) do
|
||||
base_classes = "join-item btn btn-sm"
|
||||
current_value = Map.get(group_filters, to_string(group_id))
|
||||
is_active = current_value == expected_value
|
||||
|
||||
cond do
|
||||
expected_value == nil ->
|
||||
if is_active do
|
||||
"#{base_classes} btn-active"
|
||||
else
|
||||
"#{base_classes} btn"
|
||||
end
|
||||
|
||||
expected_value == :in ->
|
||||
if is_active do
|
||||
"#{base_classes} btn-success btn-active"
|
||||
else
|
||||
"#{base_classes} btn"
|
||||
end
|
||||
|
||||
expected_value == :not_in ->
|
||||
if is_active do
|
||||
"#{base_classes} btn-error btn-active"
|
||||
else
|
||||
"#{base_classes} btn"
|
||||
end
|
||||
|
||||
true ->
|
||||
"#{base_classes} btn-outline"
|
||||
end
|
||||
end
|
||||
|
||||
# Get CSS classes for boolean filter label based on current state
|
||||
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
|
||||
base_classes = "join-item btn btn-sm"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue