diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex
new file mode 100644
index 0000000..657cb02
--- /dev/null
+++ b/lib/mv_web/live/components/member_filter_component.ex
@@ -0,0 +1,454 @@
+defmodule MvWeb.Components.MemberFilterComponent do
+ @moduledoc """
+ Provides the MemberFilter Live-Component.
+
+ A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields.
+ Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters.
+
+ ## Design Decisions
+
+ - Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles
+ (padding, display, hover, font sizes) that would interfere with form controls.
+ - Filter controls are form elements (fieldset, radio inputs), not menu items.
+ - Dropdown stays open when clicking filter segments to allow multiple filter changes.
+ - Uses `phx-change` on form for radio inputs instead of individual `phx-click` events.
+
+ ## Props
+ - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
+ - `: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)
+ - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
+
+ ## Events
+ - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
+ - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
+ """
+ use MvWeb, :live_component
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, :open, false)}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ socket =
+ socket
+ |> assign(:id, assigns.id)
+ |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
+ |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
+ |> assign(:boolean_filters, assigns[:boolean_filters] || %{})
+ |> assign(:member_count, assigns[:member_count] || 0)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("toggle_dropdown", _params, socket) do
+ {:noreply, assign(socket, :open, !socket.assigns.open)}
+ end
+
+ @impl true
+ def handle_event("close_dropdown", _params, socket) do
+ {:noreply, assign(socket, :open, false)}
+ end
+
+ @impl true
+ def handle_event("update_filters", params, socket) do
+ # Parse payment filter
+ payment_filter =
+ case Map.get(params, "payment_filter") do
+ "paid" -> :paid
+ "unpaid" -> :unpaid
+ _ -> nil
+ end
+
+ # Parse boolean custom field filters (including nil values for "all")
+ custom_boolean_filters_parsed =
+ params
+ |> Map.get("custom_boolean", %{})
+ |> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc ->
+ filter_value = parse_tri_state(value_str)
+ Map.put(acc, custom_field_id_str, filter_value)
+ end)
+
+ # Update payment filter if changed
+ if payment_filter != socket.assigns.cycle_status_filter do
+ send(self(), {:payment_filter_changed, payment_filter})
+ end
+
+ # Update boolean filters - send events for each changed filter
+ current_filters = socket.assigns.boolean_filters
+
+ # Process all custom field filters from form (including those set to "all"/nil)
+ # Radio buttons in a group always send a value, so all active filters are in the form
+ Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} ->
+ current_value = Map.get(current_filters, custom_field_id_str)
+
+ # Only send event if value actually changed
+ if current_value != new_value do
+ send(self(), {:boolean_filter_changed, custom_field_id_str, new_value})
+ end
+ end)
+
+ # Don't close dropdown - allow multiple filter changes
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("reset_filters", _params, socket) do
+ # Reset payment filter
+ if socket.assigns.cycle_status_filter != nil do
+ send(self(), {:payment_filter_changed, nil})
+ end
+
+ # Reset all boolean filters
+ Enum.each(socket.assigns.boolean_filters, fn {custom_field_id_str, _value} ->
+ send(self(), {:boolean_filter_changed, custom_field_id_str, nil})
+ end)
+
+ # Close dropdown after reset
+ {:noreply, assign(socket, :open, false)}
+ end
+
+ # Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false
+ defp parse_tri_state("true"), do: true
+ defp parse_tri_state("false"), do: false
+ defp parse_tri_state("all"), do: nil
+ defp parse_tri_state(_), 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)
+ end
+ end
+
+ # Get payment filter label
+ defp payment_filter_label(nil), do: gettext("All")
+ defp payment_filter_label(:paid), do: gettext("Paid")
+ defp payment_filter_label(:unpaid), do: gettext("Unpaid")
+
+ # Get boolean filter label (comma-separated list of active filter names)
+ defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
+ when map_size(boolean_filters) == 0 do
+ gettext("All")
+ end
+
+ defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
+ # Get names of active boolean filters
+ active_filter_names =
+ boolean_filters
+ |> Enum.map(fn {custom_field_id_str, _value} ->
+ Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end)
+ end)
+ |> Enum.filter(&(&1 != nil))
+ |> Enum.map(& &1.name)
+
+ # Join with comma and truncate if too long
+ label = Enum.join(active_filter_names, ", ")
+ truncate_label(label, 30)
+ end
+
+ # Truncate label if longer than max_length
+ defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label
+
+ defp truncate_label(label, max_length) do
+ String.slice(label, 0, max_length) <> "..."
+ end
+
+ # Count active boolean filters
+ defp active_boolean_filters_count(boolean_filters) do
+ map_size(boolean_filters)
+ end
+
+ # Get CSS classes for payment filter label based on current state
+ defp payment_filter_label_class(current_filter, expected_value) do
+ base_classes = "join-item btn btn-sm"
+ is_active = current_filter == expected_value
+
+ cond do
+ # All button (nil expected)
+ expected_value == nil ->
+ if is_active do
+ "#{base_classes} btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # Paid button
+ expected_value == :paid ->
+ if is_active do
+ "#{base_classes} btn-success btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # Unpaid button
+ expected_value == :unpaid ->
+ 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"
+ current_value = Map.get(boolean_filters, to_string(custom_field_id))
+ is_active = current_value == expected_value
+
+ cond do
+ # All button (nil expected)
+ expected_value == nil ->
+ if is_active do
+ "#{base_classes} btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # True button
+ expected_value == true ->
+ if is_active do
+ "#{base_classes} btn-success btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ # False button
+ expected_value == false ->
+ if is_active do
+ "#{base_classes} btn-error btn-active"
+ else
+ "#{base_classes} btn"
+ end
+
+ true ->
+ "#{base_classes} btn-outline"
+ end
+ end
+end
diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex
deleted file mode 100644
index 9caaa1f..0000000
--- a/lib/mv_web/live/components/payment_filter_component.ex
+++ /dev/null
@@ -1,147 +0,0 @@
-defmodule MvWeb.Components.PaymentFilterComponent do
- @moduledoc """
- Provides the PaymentFilter Live-Component.
-
- A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
- Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
- Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
-
- ## Props
- - `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- - `:id` - Component ID (required)
- - `:member_count` - Number of filtered members to display in badge (optional, default: 0)
-
- ## Events
- - Sends `{:payment_filter_changed, filter}` to parent when filter changes
- """
- use MvWeb, :live_component
-
- @impl true
- def mount(socket) do
- {:ok, assign(socket, :open, false)}
- end
-
- @impl true
- def update(assigns, socket) do
- socket =
- socket
- |> assign(:id, assigns.id)
- |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
- |> assign(:member_count, assigns[:member_count] || 0)
-
- {:ok, socket}
- end
-
- @impl true
- def render(assigns) do
- ~H"""
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
- """
- end
-
- @impl true
- def handle_event("toggle_dropdown", _params, socket) do
- {:noreply, assign(socket, :open, !socket.assigns.open)}
- end
-
- @impl true
- def handle_event("close_dropdown", _params, socket) do
- {:noreply, assign(socket, :open, false)}
- end
-
- @impl true
- def handle_event("select_filter", %{"filter" => filter_str}, socket) do
- filter = parse_filter(filter_str)
-
- # Close dropdown and notify parent
- socket = assign(socket, :open, false)
- send(self(), {:payment_filter_changed, filter})
-
- {:noreply, socket}
- end
-
- # Parse filter string to atom
- defp parse_filter("paid"), do: :paid
- defp parse_filter("unpaid"), do: :unpaid
- defp parse_filter(_), do: nil
-
- # Get display label for current filter
- defp filter_label(nil), do: gettext("All")
- defp filter_label(:paid), do: gettext("Paid")
- defp filter_label(:unpaid), do: gettext("Unpaid")
-end
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index bba6d8a..9fc9cdf 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -396,6 +396,44 @@ defmodule MvWeb.MemberLive.Index do
)}
end
+ @impl true
+ def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
+ # Update boolean filters map
+ updated_filters =
+ if filter_value == nil do
+ # Remove filter if nil (All option selected)
+ Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
+ else
+ # Add or update filter
+ Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
+ end
+
+ socket =
+ socket
+ |> assign(:boolean_custom_field_filters, updated_filters)
+ |> load_members()
+ |> update_selection_assigns()
+
+ # Build the URL with all params including new filter
+ query_params =
+ build_query_params(
+ socket.assigns.query,
+ socket.assigns.sort_field,
+ socket.assigns.sort_order,
+ socket.assigns.cycle_status_filter,
+ socket.assigns.show_current_cycle,
+ updated_filters
+ )
+
+ new_path = ~p"/members?#{query_params}"
+
+ {:noreply,
+ push_patch(socket,
+ to: new_path,
+ replace: true
+ )}
+ end
+
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index b2af205..394db2c 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -37,9 +37,11 @@
placeholder={gettext("Search...")}
/>
<.live_component
- module={MvWeb.Components.PaymentFilterComponent}
- id="payment-filter"
+ module={MvWeb.Components.MemberFilterComponent}
+ id="member-filter"
cycle_status_filter={@cycle_status_filter}
+ boolean_custom_fields={@boolean_custom_fields}
+ boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>