diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex
index 71227be..99ee2c5 100644
--- a/lib/mv_web/live/components/member_filter_component.ex
+++ b/lib/mv_web/live/components/member_filter_component.ex
@@ -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
)}
<.badge
@@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
<.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
+
+
0} class="mb-2">
@@ -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
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index efc1eb7..13dc89e 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index a85b4cf..f03daf0 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3897,3 +3897,78 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}"
#, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt."
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{field} from"
+msgstr "%{field} von"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{field} to"
+msgstr "%{field} bis"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active only"
+msgstr "Nur aktive"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom date fields"
+msgstr "Benutzerdefinierte Datumsfelder"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Dates"
+msgstr "Daten"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exit date"
+msgstr "Austrittsdatum"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exit date from"
+msgstr "Austrittsdatum von"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exit date to"
+msgstr "Austrittsdatum bis"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "From"
+msgstr "Von"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Inactive only"
+msgstr "Nur ehemalige"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Join date"
+msgstr "Beitrittsdatum"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Join date from"
+msgstr "Beitrittsdatum von"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Join date to"
+msgstr "Beitrittsdatum bis"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Range"
+msgstr "Zeitraum"
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "To"
+msgstr "Bis"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index b995b1a..50ceff8 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3897,3 +3897,78 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{field} from"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{field} to"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active only"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom date fields"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Dates"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exit date"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exit date from"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exit date to"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "From"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Inactive only"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Join date"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Join date from"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Join date to"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Range"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "To"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index f4526d1..9ec230f 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3897,3 +3897,78 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only."
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{field} from"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{field} to"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Active only"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Custom date fields"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Dates"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Exit date"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Exit date from"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Exit date to"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "From"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Inactive only"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Join date"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Join date from"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Join date to"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Range"
+msgstr ""
+
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "To"
+msgstr ""
diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs
index d32993c..bb55fa5 100644
--- a/test/mv_web/components/member_filter_component_test.exs
+++ b/test/mv_web/components/member_filter_component_test.exs
@@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
# Button should still contain some text (truncated version or indicator)
assert String.length(button_html) > 0
end
+
+ test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?ed_mode=all")
+
+ button_html =
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render()
+
+ # The idle label must not appear; some non-idle label is shown. This is
+ # the same observable contract as the other filter categories — the
+ # button visually communicates "a filter is active". The `btn-active`
+ # CSS class is set by the parent class= attribute but the `<.button>`
+ # core component currently composes its own class string and drops the
+ # caller-supplied one — that is a pre-existing component constraint, not
+ # specific to date filters.
+ refute button_html =~ gettext("Apply filters")
+ end
+
+ test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
+
+ button_html =
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render()
+
+ refute button_html =~ gettext("Apply filters")
+ end
+
+ test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
+
+ {:ok, view, _html} =
+ live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
+
+ button_html =
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render()
+
+ # With two distinct filter categories active, the label switches to the
+ # pluralized "N filters active" form. Without counting date filters as
+ # a category, this would show only "1 filter active" or the boolean
+ # field name.
+ assert button_html =~ "2"
+ assert button_html =~ gettext("filters active")
+ end
end
describe "badge" do
@@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
refute dropdown_html =~ "String Field"
end
+ test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ dropdown_html =
+ view
+ |> element("#member-filter div[role='dialog']")
+ |> render()
+
+ assert dropdown_html =~ gettext("Dates")
+ assert dropdown_html =~ gettext("Join date")
+ assert dropdown_html =~ gettext("Exit date")
+ # Exit-date segmented control modes.
+ assert dropdown_html =~ gettext("Active only")
+ assert dropdown_html =~ gettext("Inactive only")
+ # Built-in date inputs (always present for join_date and the ed_mode selector).
+ assert dropdown_html =~ ~s(name="jd_from")
+ assert dropdown_html =~ ~s(name="jd_to")
+ assert dropdown_html =~ ~s(name="ed_mode")
+ end
+
+ test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?ed_mode=custom")
+
+ view
+ |> element("#member-filter button[aria-haspopup='true']")
+ |> render_click()
+
+ dropdown_html =
+ view
+ |> element("#member-filter div[role='dialog']")
+ |> render()
+
+ assert dropdown_html =~ ~s(name="ed_from")
+ assert dropdown_html =~ ~s(name="ed_to")
+ end
+
+ test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
+ %{conn: conn} do
+ # DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
+ # `<.input>` wrapper rather than emitting raw `` tags carrying
+ # DaisyUI component classes (e.g. `input input-sm input-bordered`)
+ # directly in HEEX. `<.input>` is the project's single source of truth
+ # for input styling; bypassing it splits styling across many call sites.
+ #
+ # The recognizable structural fingerprint of `<.input>` is a wrapping
+ # `