diff --git a/lib/mv_web/live/components/add_filter_builder_component.ex b/lib/mv_web/live/components/add_filter_builder_component.ex new file mode 100644 index 00000000..f4d2dc15 --- /dev/null +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -0,0 +1,1032 @@ +defmodule MvWeb.Components.AddFilterBuilderComponent do + @moduledoc """ + The member-overview add-filter builder (replaces the vertical + `MemberFilterComponent`). + + It follows the Polaris-style add-filter flow (Option C, append-right): + + * a "+ Add filter" trigger opens a combobox **field picker** grouped + Quick / Membership / Custom fields (see `FilterDescriptor`); + * picking a field closes the picker and opens a focused, type-aware **value + control** to the right of the existing chips (no detail "Add" button); + * a valid value selection **commits** an applied-filter chip and re-loads the + list; dismissing the pending control without a selection **drops** it with + no re-load; + * applied filters render as **one compact chip per value**, each a real + remove button (`aria-label="Remove filter: …"`, ≥24px target); + * a **Clear all** control removes every active filter in one re-load. + + Filter state itself lives in the parent LiveView assigns; this component is a + pure input layer. It emits the parent's existing filter messages unchanged: + + * `{:group_filter_changed, id_str, :in | :not_in | nil}` + * `{:fee_type_filter_changed, id_str, :in | :not_in | nil}` + * `{:boolean_filter_changed, id_str, true | false | nil}` + * `{:date_filters_changed, date_filters}` + + plus the period-scoped payment messages (§3.3): + + * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3}}` + * `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}` + + and `{:clear_all_filters}` for Clear all. + """ + use MvWeb, :live_component + + alias Mv.Constants + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.FilterDescriptor + alias MvWeb.MemberLive.Index.FilterParams + alias MvWeb.MemberLive.Index.PaymentAging + + @group_filter_prefix Constants.group_filter_prefix() + @fee_type_filter_prefix Constants.fee_type_filter_prefix() + @custom_date_filter_prefix Constants.custom_date_filter_prefix() + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:open_picker, false) + |> assign(:picker_query, "") + |> assign(:pending, nil)} + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(:id, assigns.id) + |> assign_membership_inputs(assigns) + |> assign_extra_inputs(assigns)} + end + + defp assign_membership_inputs(socket, assigns) do + socket + |> assign(:groups, assigns[:groups] || []) + |> assign(:group_filters, assigns[:group_filters] || %{}) + |> assign(:fee_types, assigns[:fee_types] || []) + |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) + |> assign(:group_filter_prefix, @group_filter_prefix) + |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) + end + + defp assign_extra_inputs(socket, assigns) do + socket + |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) + |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) + |> assign(:date_custom_fields, assigns[:date_custom_fields] || []) + |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) + |> assign(:payment_filter, assigns[:payment_filter]) + |> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period()) + end + + # -------------------------------------------------------------------------- + # Render + # -------------------------------------------------------------------------- + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:chips, applied_chips(assigns)) + |> assign(:descriptors, descriptors(assigns)) + + ~H""" +
+
+ + + <%!-- Field picker: an anchored dropdown (absolute, top-layer via z-index) + so opening it never reflows the toolbar. Sized to content — narrow and + tall, capped at 70vh with the option list scrolling internally. --%> + +
+ + + + <%!-- Pending filter: the picked field renders a chip anchor and its + type-aware value control opens as an anchored dropdown below it (absolute, + top-layer), so the toolbar never reflows while choosing a value. Clicking + the chip (or Escape / click-away) dismisses without committing (§1.3). --%> +
+ +
+ {render_value_control(assigns)} +
+
+ + +
+ """ + end + + # --- value controls ------------------------------------------------------- + + defp render_value_control(%{pending: %{control: :payment_count}} = assigns) do + ~H""" +
+
+ {gettext("Payment")} + +
+ <.input + type="date" + name="pay_from" + label={gettext("Period from")} + class="input input-sm input-bordered" + value={date_iso(@payment_period[:from])} + /> + <.input + type="date" + name="pay_to" + label={gettext("Period to")} + class="input input-sm input-bordered" + value={date_iso(@payment_period[:to])} + /> +
+ """ + end + + defp render_value_control(%{pending: %{control: :active_former}} = assigns) do + ~H""" +
+
+ {gettext("Active / former")} +
+ + + +
+
+
+ """ + end + + defp render_value_control(%{pending: %{control: :group_membership}} = assigns) do + ~H""" +
+
+ {group.name} +
+ <.in_not_in_radios + name={"#{@group_filter_prefix}#{group.id}"} + current={Map.get(@group_filters, to_string(group.id))} + /> +
+
+
+ """ + end + + defp render_value_control(%{pending: %{control: :fee_type_membership}} = assigns) do + ~H""" +
+
+ {ft.name} +
+ <.in_not_in_radios + name={"#{@fee_type_filter_prefix}#{ft.id}"} + current={Map.get(@fee_type_filters, to_string(ft.id))} + /> +
+
+
+ """ + end + + defp render_value_control(%{pending: %{control: :boolean, key: key}} = assigns) do + assigns = assign(assigns, :field_id, to_string(key)) + + ~H""" +
+
+ {@pending.label} +
+ + + +
+
+
+ """ + end + + defp render_value_control(%{pending: %{control: :exit_date}} = assigns) do + ~H""" +
+
+ {gettext("Exit date")} +
+ +
+
+ <.input + type="date" + name="ed_from" + label={gettext("From")} + class="input input-sm input-bordered" + value={date_for(@date_filters, :exit_date, :from)} + /> + <.input + type="date" + name="ed_to" + label={gettext("To")} + class="input input-sm input-bordered" + value={date_for(@date_filters, :exit_date, :to)} + /> +
+
+
+ """ + end + + defp render_value_control(%{pending: %{control: :date_range, key: key}} = assigns) do + {from_name, to_name, from_val, to_val} = date_range_binding(assigns, key) + + assigns = + assigns + |> assign(:from_name, from_name) + |> assign(:to_name, to_name) + |> assign(:from_val, from_val) + |> assign(:to_val, to_val) + + ~H""" +
+
+ {@pending.label} +
+ +
+
+ <.input + type="date" + name={@from_name} + label={gettext("From")} + class="input input-sm input-bordered" + value={@from_val} + /> + <.input + type="date" + name={@to_name} + label={gettext("To")} + class="input input-sm input-bordered" + value={@to_val} + /> +
+
+
+ """ + end + + defp render_value_control(assigns), do: ~H"" + + # -------------------------------------------------------------------------- + # Events + # -------------------------------------------------------------------------- + + @impl true + def handle_event("toggle_picker", _params, socket) do + # Toggle so a second click on the trigger closes the open picker (matching the + # standard daisyUI dropdown behaviour). Opening it also clears any pending + # value control and resets the query. + if socket.assigns.open_picker do + {:noreply, assign(socket, :open_picker, false)} + else + {:noreply, + socket |> assign(:open_picker, true) |> assign(:pending, nil) |> assign(:picker_query, "")} + end + end + + @impl true + def handle_event("dismiss", _params, socket) do + # Drop-on-dismiss (§1.3): closing the picker or a pending value control + # without a selection adds no chip and triggers no re-load. + {:noreply, socket |> assign(:open_picker, false) |> assign(:pending, nil)} + end + + @impl true + def handle_event("filter_fields", %{"picker_query" => q}, socket) do + {:noreply, assign(socket, :picker_query, q)} + end + + @impl true + def handle_event("pick_field", %{"key" => key}, socket) do + descriptor = Enum.find(descriptors(socket.assigns), &(to_string(&1.key) == key)) + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:picker_query, "") + |> assign(:pending, descriptor)} + end + + @impl true + def handle_event("apply_preset", %{"preset" => preset, "key" => key}, socket) do + case preset_range(preset) do + nil -> + {:noreply, socket} + + {from, to} -> + new_filters = put_date_range(socket.assigns.date_filters, key, from, to) + + maybe_send( + new_filters != socket.assigns.date_filters, + {:date_filters_changed, new_filters} + ) + + {:noreply, assign(socket, :pending, nil)} + end + end + + @impl true + def handle_event("commit", params, socket) do + dispatch_payment(socket, params) + dispatch_quick(socket, params) + + dispatch_prefix( + socket, + params, + @group_filter_prefix, + :group_filter_changed, + socket.assigns.groups + ) + + dispatch_prefix( + socket, + params, + @fee_type_filter_prefix, + :fee_type_filter_changed, + socket.assigns.fee_types + ) + + dispatch_boolean(socket, params) + dispatch_dates(socket, params) + + {:noreply, assign(socket, :pending, nil)} + end + + @impl true + def handle_event("remove_chip", %{"kind" => kind, "id" => id}, socket) do + remove_chip(kind, id, socket) + {:noreply, socket} + end + + @impl true + def handle_event("clear_all", _params, socket) do + send(self(), {:clear_all_filters}) + {:noreply, socket |> assign(:open_picker, false) |> assign(:pending, nil)} + end + + # --- dispatch helpers ----------------------------------------------------- + + defp dispatch_payment(socket, params) do + case Map.get(params, "pay") do + nil -> + :ok + + value -> + filter = PaymentAging.parse_filter(value) + maybe_send(filter != socket.assigns.payment_filter, {:payment_filter_changed, filter}) + + period = %{ + from: parse_date(Map.get(params, "pay_from")), + to: parse_date(Map.get(params, "pay_to")) + } + + maybe_send(period != socket.assigns.payment_period, {:payment_period_changed, period}) + end + end + + defp dispatch_quick(socket, params) do + case Map.get(params, "quick") do + q when q in ["active", "former", "all"] -> + new_filters = + DateFilter.set_quick_state(socket.assigns.date_filters, String.to_existing_atom(q)) + + maybe_send( + new_filters != socket.assigns.date_filters, + {:date_filters_changed, new_filters} + ) + + _ -> + :ok + end + end + + defp dispatch_prefix(socket, params, prefix, message, valid_records) do + if Enum.any?(params, fn {k, _} -> String.starts_with?(k, prefix) end) do + dispatch_prefix_entries(socket, params, prefix, message, valid_records) + end + end + + defp dispatch_prefix_entries(socket, params, prefix, message, valid_records) do + current = current_for(socket, message) + valid_ids = MapSet.new(Enum.map(valid_records, &to_string(&1.id))) + + params + |> FilterParams.parse_prefix_filters(prefix, &FilterParams.parse_in_not_in_value/1) + |> Enum.each(&maybe_send_prefix(&1, current, valid_ids, message)) + end + + defp maybe_send_prefix({id_str, value}, current, valid_ids, message) do + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != value do + send(self(), {message, id_str, value}) + end + end + + defp current_for(socket, :group_filter_changed), do: socket.assigns.group_filters + defp current_for(socket, :fee_type_filter_changed), do: socket.assigns.fee_type_filters + + defp dispatch_boolean(socket, params) do + case Map.get(params, "custom_boolean") do + map when is_map(map) -> + Enum.each(map, &maybe_send_boolean(&1, socket.assigns.boolean_filters)) + + _ -> + :ok + end + end + + defp maybe_send_boolean({id_str, value_str}, current) do + value = parse_tri_state(value_str) + + if Map.get(current, id_str) != value do + send(self(), {:boolean_filter_changed, id_str, value}) + end + end + + defp dispatch_dates(socket, params) do + if has_date_param?(params) do + new_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields) + maybe_send(new_filters != socket.assigns.date_filters, {:date_filters_changed, new_filters}) + end + end + + defp has_date_param?(params) do + Enum.any?(params, fn {k, _} -> + k in ["ed_mode", "ed_from", "ed_to", "jd_from", "jd_to"] or + String.starts_with?(k, @custom_date_filter_prefix) + end) + end + + defp remove_chip("payment", _id, socket) do + maybe_send(socket.assigns.payment_filter != nil, {:payment_filter_changed, nil}) + end + + defp remove_chip("payment_period", _id, _socket) do + send(self(), {:payment_period_changed, PaymentAging.default_period()}) + end + + defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil}) + defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil}) + defp remove_chip("boolean", id, _socket), do: send(self(), {:boolean_filter_changed, id, nil}) + + defp remove_chip("date", id, socket) do + new_filters = clear_date_field(socket.assigns.date_filters, id) + send(self(), {:date_filters_changed, new_filters}) + end + + defp remove_chip(_kind, _id, _socket), do: :ok + + defp maybe_send(true, message), do: send(self(), message) + defp maybe_send(false, _message), do: :ok + + # -------------------------------------------------------------------------- + # Chips + # -------------------------------------------------------------------------- + + defp applied_chips(assigns) do + group_chips(assigns) ++ + fee_type_chips(assigns) ++ + boolean_chips(assigns) ++ + date_chips(assigns) ++ + payment_chips(assigns) + end + + defp group_chips(assigns) do + names = id_name_map(assigns.groups) + + for {id, value} <- assigns.group_filters do + label = + case value do + :in -> gettext("Group: %{name}", name: Map.get(names, id, id)) + :not_in -> gettext("Group: not %{name}", name: Map.get(names, id, id)) + end + + %{kind: "group", id: id, label: label} + end + end + + defp fee_type_chips(assigns) do + names = id_name_map(assigns.fee_types) + + for {id, value} <- assigns.fee_type_filters do + label = + case value do + :in -> gettext("Fee type: %{name}", name: Map.get(names, id, id)) + :not_in -> gettext("Fee type: not %{name}", name: Map.get(names, id, id)) + end + + %{kind: "fee_type", id: id, label: label} + end + end + + defp boolean_chips(assigns) do + names = id_name_map(assigns.boolean_custom_fields) + + for {id, value} <- assigns.boolean_filters do + state = if value, do: gettext("Yes"), else: gettext("No") + %{kind: "boolean", id: id, label: "#{Map.get(names, id, id)}: #{state}"} + end + end + + defp date_chips(assigns) do + filters = assigns.date_filters + + exit_chip(filters) ++ + builtin_range_chip(filters, :join_date, gettext("Joined")) ++ + custom_date_chips(assigns) + end + + defp exit_chip(filters) do + # :active_only is the default exit-date state, so it is not a user-applied + # filter and renders no chip; only the non-default states do. + case DateFilter.quick_state(filters) do + :active -> [] + :former -> [%{kind: "date", id: "exit_date", label: gettext("Former")}] + :custom -> range_chip("date", "exit_date", gettext("Exited"), Map.get(filters, :exit_date)) + :all -> [%{kind: "date", id: "exit_date", label: gettext("All (incl. former)")}] + end + end + + defp builtin_range_chip(filters, field, prefix) do + range_chip("date", to_string(field), prefix, Map.get(filters, field)) + end + + defp custom_date_chips(assigns) do + names = id_name_map(assigns.date_custom_fields) + + assigns.date_filters + |> Enum.flat_map(fn + {k, %{} = bounds} when is_binary(k) -> + range_chip("date", k, Map.get(names, k, k), bounds) + + _ -> + [] + end) + end + + defp range_chip(kind, id, prefix, %{} = bounds) do + from = bounds[:from] + to = bounds[:to] + + if from || to do + [%{kind: kind, id: id, label: "#{prefix}: #{date_iso(from) || "…"}–#{date_iso(to) || "…"}"}] + else + [] + end + end + + defp range_chip(_kind, _id, _prefix, _), do: [] + + defp payment_chips(assigns) do + filter_chip = + case assigns.payment_filter do + :fully_paid -> + [%{kind: "payment", id: "payment", label: gettext("Fully paid")}] + + {:has_unpaid, n} -> + [%{kind: "payment", id: "payment", label: gettext("≥ %{n} unpaid", n: n)}] + + _ -> + [] + end + + period = assigns.payment_period || %{} + + period_chip = + if period[:from] || period[:to] do + label = + "#{gettext("Period")}: #{date_iso(period[:from]) || "…"}–#{date_iso(period[:to]) || "…"}" + + [%{kind: "payment_period", id: "payment_period", label: label}] + else + [] + end + + filter_chip ++ period_chip + end + + # -------------------------------------------------------------------------- + # Helpers + # -------------------------------------------------------------------------- + + defp descriptors(assigns) do + FilterDescriptor.all(%{ + groups: assigns.groups, + fee_types: assigns.fee_types, + custom_fields: assigns.boolean_custom_fields ++ assigns.date_custom_fields + }) + |> filter_by_query(assigns[:picker_query]) + end + + defp filter_by_query(descriptors, q) when is_binary(q) and q != "" do + down = String.downcase(q) + Enum.filter(descriptors, &String.contains?(String.downcase(&1.label), down)) + end + + defp filter_by_query(descriptors, _q), do: descriptors + + defp id_name_map(records) do + Map.new(records, fn r -> {to_string(r.id), r.name} end) + end + + attr :name, :string, required: true + attr :current, :atom, default: nil + + defp in_not_in_radios(assigns) do + ~H""" + + + + """ + end + + defp group_label(:quick), do: gettext("Quick") + defp group_label(:membership), do: gettext("Membership") + defp group_label(:custom_fields), do: gettext("Custom fields") + + defp exit_modes do + [ + {"active_only", gettext("Active only")}, + {"all", gettext("All")}, + {"inactive_only", gettext("Former only")}, + {"custom", gettext("Range")} + ] + end + + defp exit_mode(%{exit_date: %{mode: mode}}), do: mode + defp exit_mode(_), do: :active_only + + defp quick(filters), do: DateFilter.quick_state(filters) + + defp parse_tri_state("true"), do: true + defp parse_tri_state("false"), do: false + defp parse_tri_state(_), do: nil + + defp date_iso(%Date{} = d), do: Date.to_iso8601(d) + defp date_iso(_), do: nil + + defp date_for(filters, field, bound) do + case filters do + %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) + _ -> "" + end + end + + # Builds the {from_name, to_name, from_val, to_val} tuple for a date_range + # control keyed by a built-in field (:join_date) or a custom date field UUID. + defp date_range_binding(assigns, :join_date) do + {"jd_from", "jd_to", date_for(assigns.date_filters, :join_date, :from), + date_for(assigns.date_filters, :join_date, :to)} + end + + defp date_range_binding(assigns, key) do + id = to_string(key) + bounds = Map.get(assigns.date_filters, id, %{}) + + {"#{@custom_date_filter_prefix}#{id}_from", "#{@custom_date_filter_prefix}#{id}_to", + date_iso(bounds[:from]) || "", date_iso(bounds[:to]) || ""} + end + + # --- date presets --------------------------------------------------------- + + defp date_presets do + [ + {"this_year", gettext("This year")}, + {"last_year", gettext("Last year")}, + {"this_month", gettext("This month")}, + {"last_30_days", gettext("Last 30 days")} + ] + end + + defp preset_range("this_year") do + y = Date.utc_today().year + {Date.new!(y, 1, 1), Date.new!(y, 12, 31)} + end + + defp preset_range("last_year") do + y = Date.utc_today().year - 1 + {Date.new!(y, 1, 1), Date.new!(y, 12, 31)} + end + + defp preset_range("this_month") do + today = Date.utc_today() + {Date.beginning_of_month(today), Date.end_of_month(today)} + end + + defp preset_range("last_30_days") do + today = Date.utc_today() + {Date.add(today, -30), today} + end + + defp preset_range(_), do: nil + + # Writes a from/to range for a built-in or custom date field into the date + # filter map, matching DateFilter's internal shape. + defp put_date_range(filters, "join_date", from, to) do + Map.put(filters, :join_date, %{from: from, to: to}) + end + + defp put_date_range(filters, "exit_date", from, to) do + Map.put(filters, :exit_date, %{mode: :custom, from: from, to: to}) + end + + defp put_date_range(filters, key, from, to) when is_binary(key) do + Map.put(filters, key, %{from: from, to: to}) + end + + defp clear_date_field(filters, "join_date"), + do: Map.put(filters, :join_date, %{from: nil, to: nil}) + + defp clear_date_field(filters, "exit_date") do + Map.put(filters, :exit_date, %{mode: :active_only, from: nil, to: nil}) + end + + defp clear_date_field(filters, key) when is_binary(key), do: Map.delete(filters, key) + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex deleted file mode 100644 index a0753387..00000000 --- a/lib/mv_web/live/components/member_filter_component.ex +++ /dev/null @@ -1,1016 +0,0 @@ -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 with legend, radio inputs), not menu items. - Uses semantic `
` and `` for proper accessibility and form structure. - - 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` - - `: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). - Multiple active filters combine with AND (member must match all selected group conditions). - - `:fee_types` - List of membership fee types (for per-fee-type filter rows) - - `: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) - - ## 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 `{: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 - {: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_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""" -
- <.button - type="button" - variant="secondary" - class={[ - "gap-2", - (@cycle_status_filter || map_size(@group_filters) > 0 || - map_size(@fee_type_filters) > 0 || - active_boolean_filters_count(@boolean_filters) > 0 || - date_filters_active?(@date_filters)) && - "btn-active" - ]} - phx-click="toggle_dropdown" - phx-target={@myself} - aria-haspopup="true" - aria-expanded={to_string(@open)} - aria-label={gettext("Filter members")} - > - <.icon name="hero-funnel" class="h-5 w-5" /> - - <.badge - :if={active_boolean_filters_count(@boolean_filters) > 0} - variant="primary" - size="sm" - > - {active_boolean_filters_count(@boolean_filters)} - - <.badge - :if={ - (@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" - size="sm" - > - {@member_count} - - <.icon name="hero-chevron-down" class="size-4" /> - - - - -
- """ - 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 - payment_filter = parse_payment_filter(params) - - group_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @group_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) - - fee_type_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @fee_type_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) - - 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 - - @impl true - 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, - %{ - cycle_status_filter: nil, - boolean_filters: %{}, - group_filters: %{}, - fee_type_filters: %{} - }} - ) - - # 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 - - defp parse_payment_filter(params) do - case Map.get(params, "payment_filter") do - "paid" -> :paid - "unpaid" -> :unpaid - _ -> nil - end - end - - defp parse_custom_boolean_filters(params) do - params - |> Map.get("custom_boolean", %{}) - |> Enum.reduce(%{}, fn {id_str, value_str}, acc -> - Map.put(acc, id_str, parse_tri_state(value_str)) - end) - end - - defp dispatch_payment_filter_change(socket, payment_filter) do - if payment_filter != socket.assigns.cycle_status_filter do - send(self(), {:payment_filter_changed, payment_filter}) - end - end - - defp dispatch_group_filter_changes(socket, group_filters_parsed) do - current = socket.assigns.group_filters - valid_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) - - Enum.each(group_filters_parsed, fn {id_str, new_value} -> - if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do - send(self(), {:group_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) do - current = socket.assigns.fee_type_filters - valid_ids = MapSet.new(Enum.map(socket.assigns.fee_types, &to_string(&1.id))) - - Enum.each(fee_type_filters_parsed, fn {id_str, new_value} -> - if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do - send(self(), {:fee_type_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) do - current = socket.assigns.boolean_filters - - Enum.each(custom_boolean_filters_parsed, fn {id_str, new_value} -> - if Map.get(current, id_str) != new_value do - send(self(), {:boolean_filter_changed, id_str, new_value}) - end - 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, - groups, - group_filters, - fee_types, - fee_type_filters, - boolean_custom_fields, - boolean_filters, - date_filters - ) do - active_count = - count_active_filter_categories( - cycle_status_filter, - group_filters, - fee_type_filters, - boolean_filters, - date_filters - ) - - if active_count >= 2 do - ngettext("%{count} filter active", "%{count} filters active", active_count, - count: active_count - ) - else - cond do - cycle_status_filter -> - payment_filter_label(cycle_status_filter) - - map_size(group_filters) > 0 -> - group_filters_label(groups, group_filters) - - map_size(fee_type_filters) > 0 -> - fee_type_filters_label(fee_types, fee_type_filters) - - 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 - end - end - - defp count_active_filter_categories( - cycle_status_filter, - group_filters, - fee_type_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, - 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") - - defp group_filters_label(groups, group_filters) do - groups_by_id = Map.new(groups, fn g -> {to_string(g.id), g.name} end) - - names = - group_filters - |> Enum.map(fn {group_id_str, _} -> Map.get(groups_by_id, group_id_str) end) - |> Enum.reject(&is_nil/1) - - label = Enum.join(names, ", ") - truncate_label(label, 30) - end - - defp fee_type_filters_label(_fee_types, fee_type_filters) when map_size(fee_type_filters) == 0, - do: gettext("All") - - defp fee_type_filters_label(fee_types, fee_type_filters) do - fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) - - parts = - fee_type_filters - |> Enum.map(fn {fee_type_id_str, value} -> - fee_type_filter_part(Map.get(fee_types_by_id, fee_type_id_str), value) - end) - |> Enum.reject(&is_nil/1) - - label = Enum.join(parts, ", ") - truncate_label(label, 30) - end - - defp fee_type_filter_part(nil, _value), do: nil - - defp fee_type_filter_part(name, :not_in), do: gettext("without %{name}", name: name) - defp fee_type_filter_part(name, _), do: name - - # 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 - - # Shared CSS classes for in/not_in filter labels (groups and fee types) - defp in_not_in_filter_label_class(filters, id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(filters, to_string(id)) - is_active = current_value == expected_value - - case {expected_value, is_active} do - {_, false} -> "#{base_classes} btn" - {nil, true} -> "#{base_classes} btn-active" - {:in, true} -> "#{base_classes} btn-success btn-active" - {:not_in, true} -> "#{base_classes} btn-error btn-active" - end - end - - defp group_filter_label_class(group_filters, group_id, expected_value) do - in_not_in_filter_label_class(group_filters, group_id, expected_value) - end - - defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do - in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value) - 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 - - # --- 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.ex b/lib/mv_web/live/member_live/index.ex index 6933e005..b519aa2d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -24,14 +24,14 @@ defmodule MvWeb.MemberLive.Index do - `select_row_and_navigate` - open a member's show page - `load_more` - fetch and append the next keyset page (infinite scroll) - `sort` - change the active sort column/direction - - `toggle_cycle_view` - switch the payment-status view between current and last cycle - `copy_emails` - copy emails of the selected members, or of the whole filtered set when none are selected ## Messages (`handle_info`, from the filter/search/view-settings components) - `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`, - `fields_reset`, `reset_all_filters`, and the per-filter change messages - (`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`, - `date_filters_changed`, `payment_filter_changed`) + `fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter + change messages (`group_filter_changed`, `fee_type_filter_changed`, + `boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`, + `payment_period_changed`) ## Implementation Notes - Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only @@ -58,6 +58,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.OverviewQuery + alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.ViewSettings require Ash.Query @@ -174,7 +175,8 @@ defmodule MvWeb.MemberLive.Index do |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) - |> assign(:cycle_status_filter, nil) + |> assign(:payment_filter, nil) + |> assign(:payment_period, PaymentAging.default_period()) |> assign(:group_filters, %{}) |> assign(:groups, groups) |> assign(:fee_type_filters, %{}) @@ -203,8 +205,6 @@ defmodule MvWeb.MemberLive.Index do :member_fields_visible_computed, FieldVisibility.get_visible_member_fields_computed(initial_selection) ) - |> assign(:show_current_cycle, false) - |> assign(:membership_fee_status_filter, nil) |> assign(:page, nil) |> assign(:after_cursor, nil) |> assign(:more?, false) @@ -309,29 +309,6 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns()} end - @impl true - def handle_event("toggle_cycle_view", _params, socket) do - new_show_current = !socket.assigns.show_current_cycle - - socket = - socket - |> assign(:show_current_cycle, new_show_current) - |> load_members() - |> scroll_list_to_top() - |> update_selection_assigns() - - query_params = - build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - new_path = ~p"/members?#{query_params}" - - {:noreply, push_reload(socket, new_path)} - end - @impl true def handle_event("copy_emails", _params, socket) do # Recipients follow the current scope, re-queried from the DB so a no-selection @@ -449,13 +426,13 @@ defmodule MvWeb.MemberLive.Index do def handle_info({:payment_filter_changed, filter}, socket) do socket = socket - |> assign(:cycle_status_filter, filter) + |> assign(:payment_filter, filter) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = - build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) + build_query_params(opts_for_query_params(socket, %{payment_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -465,6 +442,42 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_reload(socket, new_path)} end + @impl true + def handle_info({:payment_period_changed, period}, socket) do + socket = + socket + |> assign(:payment_period, period) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{payment_period: period})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_reload(socket, new_path)} + end + + @impl true + def handle_info({:clear_all_filters}, socket) do + handle_info( + {:reset_all_filters, + %{ + payment_filter: nil, + payment_period: PaymentAging.default_period(), + group_filters: %{}, + fee_type_filters: %{}, + boolean_filters: %{}, + date_filters: DateFilter.default() + }}, + socket + ) + end + @impl true def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do updated_filters = @@ -573,7 +586,8 @@ defmodule MvWeb.MemberLive.Index do def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket - |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) + |> assign(:payment_filter, Map.get(opts, :payment_filter)) + |> assign(:payment_period, Map.get(opts, :payment_period, PaymentAging.default_period())) |> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) @@ -663,12 +677,12 @@ defmodule MvWeb.MemberLive.Index do socket |> maybe_update_search(params) |> maybe_update_sort(params) - |> maybe_update_cycle_status_filter(params) + |> maybe_update_payment_filter(params) + |> maybe_update_payment_period(params) |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_date_filters(params) - |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) |> assign_visibility_derivations(final_selection) @@ -712,10 +726,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, - socket.assigns.cycle_status_filter, + socket.assigns.payment_filter, + socket.assigns.payment_period, socket.assigns[:group_filters], socket.assigns[:fee_type_filters], - socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, socket.assigns[:visible_custom_field_ids] || [], @@ -866,14 +880,21 @@ defmodule MvWeb.MemberLive.Index do defp build_query_params(opts) when is_map(opts) do base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) - base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) - base_params = add_show_current_cycle(base_params, opts.show_current_cycle) + base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) add_date_filters(base_params, opts.date_filters) end + # Period-scoped payment model (§3.3): the payment-count filter and the active + # period both serialize into the flat URL contract (pay_filter/pay_from/pay_to). + defp add_payment_params(params, payment_filter, payment_period) do + params + |> Map.merge(PaymentAging.filter_to_params(payment_filter)) + |> Map.merge(PaymentAging.to_params(payment_period || PaymentAging.default_period())) + end + defp add_date_filters(params, date_filters) do Map.merge(params, DateFilter.to_params(date_filters)) end @@ -883,9 +904,9 @@ defmodule MvWeb.MemberLive.Index do query: socket.assigns.query, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, - cycle_status_filter: socket.assigns.cycle_status_filter, + payment_filter: socket.assigns.payment_filter, + payment_period: socket.assigns.payment_period, group_filters: socket.assigns[:group_filters] || %{}, - show_current_cycle: socket.assigns.show_current_cycle, boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, fee_type_filters: socket.assigns[:fee_type_filters] || %{}, date_filters: socket.assigns.date_filters @@ -1187,17 +1208,6 @@ defmodule MvWeb.MemberLive.Index do end) end - defp add_cycle_status_filter(params, nil), do: params - defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid") - - defp add_cycle_status_filter(params, :unpaid), - do: Map.put(params, "cycle_status_filter", "unpaid") - - defp add_cycle_status_filter(params, _), do: params - - defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true") - defp add_show_current_cycle(params, _), do: params - defp add_boolean_filters(params, boolean_filters) do Enum.reduce(boolean_filters, params, &add_boolean_filter/2) end @@ -1319,10 +1329,19 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.select(@overview_fields) |> load_custom_field_values(compute_ids_to_load(socket)) |> MembershipFeeStatus.load_cycles_for_members() + |> load_unpaid_cycle_count(socket) |> Ash.Query.load(groups: [:id, :name, :slug]) |> maybe_load_fee_type(socket) end + # Loads the period-scoped unpaid-cycle count for the overview payment column + # (§1.13, §3.3). The count is a DB aggregate over `membership_fee_cycles` + # (denormalized `cycle_end`), scoped to the active period. + defp load_unpaid_cycle_count(query, socket) do + %{from: from, to: to} = socket.assigns.payment_period || PaymentAging.default_period() + Ash.Query.load(query, unpaid_cycle_count: %{period_from: from, period_to: to}) + end + defp maybe_load_fee_type(query, socket) do if :membership_fee_type in socket.assigns.member_fields_visible or socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do @@ -1345,8 +1364,8 @@ defmodule MvWeb.MemberLive.Index do boolean_custom_fields: socket.assigns.boolean_custom_fields, date_filters: socket.assigns.date_filters, date_custom_fields: socket.assigns[:date_custom_fields], - cycle_status_filter: socket.assigns.cycle_status_filter, - show_current_cycle: socket.assigns.show_current_cycle, + payment_filter: socket.assigns.payment_filter, + payment_period: socket.assigns.payment_period, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, custom_fields: socket.assigns.all_custom_fields @@ -1550,13 +1569,14 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_search(socket, _params), do: socket - defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do - filter = determine_cycle_status_filter(filter_str) - assign(socket, :cycle_status_filter, filter) - end + # Period-scoped payment model (§3.3): a payment-count filter plus the active + # period, both driven purely from the URL so `handle_params` stays the source + # of truth. Absent params fall back to no filter / the all-outstanding period. + defp maybe_update_payment_filter(socket, params), + do: assign(socket, :payment_filter, PaymentAging.parse_filter_params(params)) - defp maybe_update_cycle_status_filter(socket, _params), - do: assign(socket, :cycle_status_filter, nil) + defp maybe_update_payment_period(socket, params), + do: assign(socket, :payment_period, PaymentAging.parse_period(params)) defp maybe_update_group_filters(socket, params) when is_map(params) do prefix = @group_filter_prefix @@ -1652,10 +1672,6 @@ defmodule MvWeb.MemberLive.Index do defp normalize_uuid_string(_), do: nil - defp determine_cycle_status_filter("paid"), do: :paid - defp determine_cycle_status_filter("unpaid"), do: :unpaid - defp determine_cycle_status_filter(_), do: nil - defp maybe_update_boolean_filters(socket, params) do boolean_custom_fields = socket.assigns.all_custom_fields @@ -1728,11 +1744,6 @@ defmodule MvWeb.MemberLive.Index do defp determine_boolean_filter("false"), do: false defp determine_boolean_filter(_), do: nil - defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}), - do: assign(socket, :show_current_cycle, true) - - defp maybe_update_show_current_cycle(socket, _params), do: socket - # URL params are the source of truth for filter state on every navigation. # When no date filter params are present, this falls through to the # active_only default — exactly the spec behavior for fresh load (§1.1). @@ -1820,6 +1831,23 @@ defmodule MvWeb.MemberLive.Index do def format_date(date), do: DateFormatter.format_date(date) + @doc """ + The payment column header, naming the active aging period (§1.17). The + all-outstanding default (both bounds nil) reads just "Payment"; a bounded + period appends the range so the scope is always legible. + """ + def payment_column_label(%{from: nil, to: nil}), do: gettext("Fees") + + def payment_column_label(%{from: from, to: to}) do + range = "#{payment_bound(from)}–#{payment_bound(to)}" + gettext("Fees · %{range}", range: range) + end + + def payment_column_label(_), do: gettext("Fees") + + defp payment_bound(%Date{} = d), do: Date.to_iso8601(d) + defp payment_bound(_), do: "…" + defp update_selection_assigns(socket) do selected_members = socket.assigns.selected_members # The selection may span members beyond the loaded page (after select-all), @@ -1939,7 +1967,7 @@ defmodule MvWeb.MemberLive.Index do end defp selection_filters_active?(assigns) do - not is_nil(assigns[:cycle_status_filter]) or + not is_nil(assigns[:payment_filter]) or map_size(assigns[:group_filters] || %{}) > 0 or map_size(assigns[:fee_type_filters] || %{}) > 0 or map_size(assigns[:boolean_custom_field_filters] || %{}) > 0 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 7709c2cb..5a56e922 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -21,17 +21,38 @@ -
+ <%!-- Two-row overview toolbar (Option C): row 1 is search + column/view + controls; row 2 is the dedicated filter row (add-filter + applied chips). --%> +
+
+ <.live_component + module={MvWeb.Components.SearchBarComponent} + id="search-bar" + query={@query} + placeholder={gettext("Search...")} + /> +
+ <.live_component + module={MvWeb.Components.FieldVisibilityDropdownComponent} + id="field-visibility-dropdown" + all_fields={@all_available_fields} + custom_fields={@all_custom_fields} + selected_fields={@user_field_selection} + /> + <.live_component + module={MvWeb.Components.ViewSettingsDropdownComponent} + id="view-settings-dropdown" + density={@density} + compact_member={@compact_member} + member_include_email={@member_include_email} + compact_address={@compact_address} + /> +
+
+ <.live_component - module={MvWeb.Components.SearchBarComponent} - id="search-bar" - query={@query} - placeholder={gettext("Search...")} - /> - <.live_component - module={MvWeb.Components.MemberFilterComponent} + module={MvWeb.Components.AddFilterBuilderComponent} id="member-filter" - cycle_status_filter={@cycle_status_filter} groups={@groups} group_filters={@group_filters} fee_types={@fee_types} @@ -40,57 +61,9 @@ boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} - member_count={@total_count} + payment_filter={@payment_filter} + payment_period={@payment_period} /> - <.tooltip - content={ - gettext( - "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." - ) - } - position="top" - > - <.button - type="button" - variant="secondary" - class="gap-2" - active={@show_current_cycle} - aria-pressed={to_string(@show_current_cycle)} - phx-click="toggle_cycle_view" - data-testid="toggle-cycle-view" - aria-label={ - if(@show_current_cycle, - do: gettext("Current payment cycle"), - else: gettext("Last payment cycle") - ) - } - > - <.icon name="hero-arrow-path" class="h-5 w-5" /> - - - -
- <.live_component - module={MvWeb.Components.FieldVisibilityDropdownComponent} - id="field-visibility-dropdown" - all_fields={@all_available_fields} - custom_fields={@all_custom_fields} - selected_fields={@user_field_selection} - /> - <.live_component - module={MvWeb.Components.ViewSettingsDropdownComponent} - id="view-settings-dropdown" - density={@density} - compact_member={@compact_member} - member_include_email={@member_include_email} - compact_address={@compact_address} - /> -
<%!-- Polite live region: present on first render (before it is filled) so @@ -131,7 +104,6 @@ JS.push("select_row_and_navigate", value: %{id: member.id}) end } - row_tooltip={gettext("Click for member details")} row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} sort_field={@sort_field} @@ -484,17 +456,52 @@ <:col :let={member} :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} + label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} > - <%= if badge = MembershipFeeStatus.format_cycle_status_badge( - MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) - ) do %> - <.badge variant={badge.variant}> - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - <.empty_cell sr_text={gettext("No cycle")} /> + <% count = member.unpaid_cycle_count || 0 %> + <% badge = PaymentAging.badge(count) %> + <% tip_id = "payment-tip-#{member.id}" %> + + <%= if count > 0 do %> + <% cycles = PaymentAging.open_cycles(member, @payment_period) %> + <% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %> + <% end %> <:col diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index a26abae2..ca6ed704 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -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}) diff --git a/lib/mv_web/live/member_live/index/export_payload.ex b/lib/mv_web/live/member_live/index/export_payload.ex index 3c15c611..637da8a4 100644 --- a/lib/mv_web/live/member_live/index/export_payload.ex +++ b/lib/mv_web/live/member_live/index/export_payload.ex @@ -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 || [] diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex index 96ecd801..0e80dc5a 100644 --- a/lib/mv_web/live/member_live/index/filter_descriptor.ex +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -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 """ diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index 1afe499b..a45dd077 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -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) # diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index 9dbe3e0c..e6154aa7 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -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 diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index f1748591..d3979f46 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do end @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params(%{"id" => id} = params, _, socket) do actor = current_actor(socket) # Load custom fields once using assign_new to avoid repeated queries @@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do {:noreply, socket |> Layouts.assign_page_title(content_title) - |> assign(:member, member)} + |> assign(:member, member) + |> maybe_assign_tab(params)} end + # Deep-link support: the overview payment badge drills into the fees history + # via `?tab=membership_fees` (§1.15). + defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}), + do: assign(socket, :active_tab, :membership_fees) + + defp maybe_assign_tab(socket, _params), do: socket + @impl true def handle_event("switch_tab", %{"tab" => "contact"}, socket) do {:noreply, assign(socket, :active_tab, :contact)} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 8749a6a5..a8243b50 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -20,13 +20,6 @@ msgstr " (Datenfeld: %{field})" msgid "%{count} failed" msgstr "%{count} fehlgeschlagen" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "%{count} Filter aktiv" -msgstr[1] "%{count} Filter aktiv" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield." @@ -39,16 +32,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z msgid "%{count} synced" msgstr "%{count} synchronisiert" -#: 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/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -106,7 +89,7 @@ msgstr "Aktionen" msgid "Active members" msgstr "Aktive Mitglieder" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Active only" msgstr "Nur aktive" @@ -149,7 +132,7 @@ msgid "Administration" msgstr "Administration" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -210,11 +193,6 @@ msgstr "App-URL (Link zur Kontaktansicht)" msgid "Applicant data" msgstr "Angaben des Antragstellers" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "Filter auswählen" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -489,11 +467,6 @@ msgstr "CSV-Datei auswählen" msgid "City" msgstr "Stadt" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "Filter zurücksetzen" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -504,11 +477,6 @@ msgstr "Klicken für Details" msgid "Click for group details" msgstr "Klicke für Gruppen-Details" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "Klicke für Mitglieds-Details" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -550,11 +518,6 @@ msgstr "Client-ID" msgid "Client Secret" msgstr "Client-Geheimnis" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Close" -msgstr "Schließen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -816,11 +779,6 @@ msgstr "Aktueller Zyklus" msgid "Current amount" msgstr "Aktueller Betrag" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "Aktueller Zahlungszyklus" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" @@ -831,11 +789,6 @@ msgstr "Benutzerdefiniert" msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "Benutzerdefinierte Datumsfelder" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Custom field" @@ -915,11 +868,6 @@ msgstr "Datenfelder" msgid "Date" msgstr "Datum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "Daten" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1296,23 +1244,13 @@ msgstr "Beispiele" msgid "Exit Date" msgstr "Austrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_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/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1487,11 +1425,6 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart." -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Fee types" -msgstr "Beitragsarten" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." @@ -1508,11 +1441,6 @@ msgstr "Feld" msgid "Fields on the join form" msgstr "Felder im Beitrittsformular" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "Mitglieder filtern" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1532,7 +1460,7 @@ msgstr "Vorname" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln." -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "Von" @@ -1659,7 +1587,6 @@ msgid "Group saved successfully." msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1774,11 +1701,6 @@ msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden." msgid "Inactive members" msgstr "Inaktive Mitglieder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "Nur ehemalige" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1814,7 +1736,6 @@ msgstr "Falsche E-Mail oder Passwort" msgid "Individual Datafields" msgstr "Individuelle Datenfelder" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1907,22 +1828,11 @@ msgstr "Beitrittsformular" msgid "Join confirmation" msgstr "Beitrittsbestätigung" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.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/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2012,11 +1922,6 @@ msgstr "Nachname" msgid "Last name" msgstr "Nachname" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "Letzter Zahlungszyklus" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2111,6 +2016,7 @@ msgstr "Als ausgesetzt markieren" msgid "Mark as unpaid" msgstr "Als unbezahlt markieren" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2157,11 +2063,6 @@ msgstr "Mitgliedsfeld" msgid "Member field %{action} successfully" msgstr "Mitgliedsfeld wurde erfolgreich %{action}" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Member filter" -msgstr "Mitgliedsfilter" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2247,7 +2148,6 @@ msgstr "Mitgliedsbeitrag" msgid "Membership Fee Start Date" msgstr "Startdatum Mitgliedsbeitrag" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Status" @@ -2411,7 +2311,7 @@ msgid "New amount" msgstr "Neuer Betrag" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2433,11 +2333,6 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "No approved or rejected requests yet" msgstr "Noch keine genehmigten oder abgelehnten Anträge" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "Kein Zyklus" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2697,9 +2592,7 @@ msgid "Options" msgstr "Optionen" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex -#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -2734,16 +2627,6 @@ msgstr "Beitragsdaten" msgid "Payment Interval" msgstr "Zahlungsintervall" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Status" -msgstr "Bezahlstatus" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "Zahlungen" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2842,7 +2725,7 @@ msgstr "Vierteljährlich" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Range" msgstr "Zeitraum" @@ -3266,11 +3149,6 @@ msgstr "Server nicht erreichbar. Host und Port prüfen." msgid "Set Password" msgstr "Passwort setzen" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden." - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Settings saved successfully." @@ -3600,7 +3478,7 @@ msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier f msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "Bis" @@ -3700,7 +3578,6 @@ msgid "Unlinking scheduled" msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -3906,7 +3783,7 @@ msgid "Yearly Interval - Joining Cycle Included" msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4129,11 +4006,6 @@ msgstr "aktualisiert" msgid "updated" msgstr "aktualisiert" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "ohne %{name}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4218,22 +4090,284 @@ msgstr "Klicke, um nach Nachname zu sortieren" msgid "No address" msgstr "Keine Adresse" -#: lib/mv_web/live/member_live/index/payment_aging.ex -#, elixir-autogen, elixir-format -msgid "%{count} unpaid" -msgstr "%{count} unbezahlt" - +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Active / former" msgstr "Aktiv / ehemalig" #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Group" -msgstr "Gruppen" +msgstr "Gruppe" +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Payment" -msgstr "Zahlungen" +msgstr "Zahlung" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{label} — open payment history" +msgstr "%{label} — Zahlungsverlauf öffnen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "Aktiv" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Add filter" +msgstr "Filter hinzufügen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "All (incl. former)" +msgstr "Alle (inkl. ehemalige)" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Available filters" +msgstr "Verfügbare Filter" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "Alle löschen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "Eigene Felder" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Exited" +msgstr "Ausgetreten" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: %{name}" +msgstr "Beitragsart: %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: not %{name}" +msgstr "Beitragsart: nicht %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter by …" +msgstr "Filtern nach …" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former" +msgstr "Ehemalig" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former only" +msgstr "Nur ehemalige" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fully paid" +msgstr "Vollständig bezahlt" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: %{name}" +msgstr "Gruppe: %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: not %{name}" +msgstr "Gruppe: nicht %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 1" +msgstr "Offen ≥ 1" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 2" +msgstr "Offen ≥ 2" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 3" +msgstr "Offen ≥ 3" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Joined" +msgstr "Beigetreten" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 30 days" +msgstr "Letzte 30 Tage" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last year" +msgstr "Letztes Jahr" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Membership" +msgstr "Mitgliedschaft" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Payment status" +msgstr "Zahlungsstatus" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period" +msgstr "Zeitraum" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period from" +msgstr "Zeitraum von" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period to" +msgstr "Zeitraum bis" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Quick" +msgstr "Schnellfilter" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Remove filter: %{label}" +msgstr "Filter entfernen: %{label}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This month" +msgstr "Dieser Monat" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This year" +msgstr "Dieses Jahr" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "≥ %{n} unpaid" +msgstr "≥ %{n} offen" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} open" +msgstr "%{count} offen" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "+%{count} more" +msgstr "+%{count} weitere" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "All paid" +msgstr "Alle bezahlt" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "April" +msgstr "April" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "August" +msgstr "August" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "December" +msgstr "Dezember" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "February" +msgstr "Februar" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees" +msgstr "Beiträge" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees · %{range}" +msgstr "Beiträge · %{range}" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "January" +msgstr "Januar" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "July" +msgstr "Juli" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "June" +msgstr "Juni" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "March" +msgstr "März" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "November" +msgstr "November" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "October" +msgstr "Oktober" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "September" +msgstr "September" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for member details" +#~ msgstr "Klicke für Mitglieds-Details" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "No open cycles for this period" +#~ msgstr "Keine offenen Zyklen in diesem Zeitraum" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Payment · %{range}" +#~ msgstr "Zahlung · %{range}" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Suspended: %{date}" +#~ msgstr "Ausgesetzt: %{date}" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Unpaid until %{date}" +#~ msgstr "Offen bis %{date}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 2e1df9f5..9de292e9 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -21,13 +21,6 @@ msgstr "" msgid "%{count} failed" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "" -msgstr[1] "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "%{count} member has a value assigned for this datafield." @@ -40,16 +33,6 @@ msgstr[1] "" msgid "%{count} synced" 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/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -107,7 +90,7 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Active only" msgstr "" @@ -150,7 +133,7 @@ msgid "Administration" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -211,11 +194,6 @@ msgstr "" msgid "Applicant data" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -490,11 +468,6 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -505,11 +478,6 @@ msgstr "" msgid "Click for group details" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -551,11 +519,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Close" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -817,11 +780,6 @@ msgstr "" msgid "Current amount" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" @@ -832,11 +790,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Custom field" @@ -916,11 +869,6 @@ msgstr "" msgid "Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1297,23 +1245,13 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_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/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1488,11 +1426,6 @@ msgstr "" msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Fee types" -msgstr "" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." @@ -1509,11 +1442,6 @@ msgstr "" msgid "Fields on the join form" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1533,7 +1461,7 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "" @@ -1660,7 +1588,6 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1775,11 +1702,6 @@ msgstr "" msgid "Inactive members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1815,7 +1737,6 @@ msgstr "" msgid "Individual Datafields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1908,22 +1829,11 @@ msgstr "" msgid "Join confirmation" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.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/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2013,11 +1923,6 @@ msgstr "" msgid "Last name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2112,6 +2017,7 @@ msgstr "" msgid "Mark as unpaid" msgstr "" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2158,11 +2064,6 @@ msgstr "" msgid "Member field %{action} successfully" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Member filter" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2248,7 +2149,6 @@ msgstr "" msgid "Membership Fee Start Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Membership Fee Status" @@ -2412,7 +2312,7 @@ msgid "New amount" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2434,11 +2334,6 @@ msgstr "" msgid "No approved or rejected requests yet" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2698,9 +2593,7 @@ msgid "Options" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex -#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -2735,16 +2628,6 @@ msgstr "" msgid "Payment Interval" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payment Status" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2843,7 +2726,7 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Range" msgstr "" @@ -3267,11 +3150,6 @@ msgstr "" msgid "Set Password" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings saved successfully." @@ -3601,7 +3479,7 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "" @@ -3701,7 +3579,6 @@ msgid "Unlinking scheduled" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -3906,7 +3783,7 @@ msgid "Yearly Interval - Joining Cycle Included" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4129,11 +4006,6 @@ msgstr "" msgid "updated" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4218,11 +4090,7 @@ msgstr "" msgid "No address" msgstr "" -#: lib/mv_web/live/member_live/index/payment_aging.ex -#, elixir-autogen, elixir-format -msgid "%{count} unpaid" -msgstr "" - +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Active / former" @@ -4233,7 +4101,248 @@ msgstr "" msgid "Group" msgstr "" +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Payment" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{label} — open payment history" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Add filter" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "All (incl. former)" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Available filters" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Exited" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter by …" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former only" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fully paid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 1" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 2" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 3" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Joined" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 30 days" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Membership" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Payment status" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period from" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period to" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Quick" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Remove filter: %{label}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This month" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "≥ %{n} unpaid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} open" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "+%{count} more" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "All paid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "April" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "August" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "December" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "February" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees · %{range}" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "January" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "July" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "June" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "March" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "November" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "October" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "September" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index faec9b03..427fb998 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -21,13 +21,6 @@ msgstr "" msgid "%{count} failed" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "%{count} filter active" -msgstr[1] "%{count} filters active" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield." @@ -40,16 +33,6 @@ msgstr[1] "" msgid "%{count} synced" 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/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -107,7 +90,7 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Active only" msgstr "" @@ -150,7 +133,7 @@ msgid "Administration" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -211,11 +194,6 @@ msgstr "" msgid "Applicant data" msgstr "Applicant data" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -490,11 +468,6 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -505,11 +478,6 @@ msgstr "Click for details" msgid "Click for group details" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -551,11 +519,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Close" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -817,11 +780,6 @@ msgstr "" msgid "Current amount" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "Current payment cycle" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Custom" @@ -832,11 +790,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom date fields" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "Custom field" @@ -916,11 +869,6 @@ msgstr "" msgid "Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Dates" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1297,23 +1245,13 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_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/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1488,11 +1426,6 @@ msgstr "" msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Fee types" -msgstr "Fee types" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." @@ -1509,11 +1442,6 @@ msgstr "" msgid "Fields on the join form" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1533,7 +1461,7 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "" @@ -1660,7 +1588,6 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1775,11 +1702,6 @@ msgstr "" msgid "Inactive members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1815,7 +1737,6 @@ msgstr "" msgid "Individual Datafields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1908,22 +1829,11 @@ msgstr "" msgid "Join confirmation" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.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/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2013,11 +1923,6 @@ msgstr "" msgid "Last name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "Last payment cycle" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2112,6 +2017,7 @@ msgstr "" msgid "Mark as unpaid" msgstr "" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2158,11 +2064,6 @@ msgstr "" msgid "Member field %{action} successfully" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member filter" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2248,7 +2149,6 @@ msgstr "" msgid "Membership Fee Start Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Status" @@ -2412,7 +2312,7 @@ msgid "New amount" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2434,11 +2334,6 @@ msgstr "" msgid "No approved or rejected requests yet" msgstr "No approved or rejected requests yet" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2698,9 +2593,7 @@ msgid "Options" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex -#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -2735,16 +2628,6 @@ msgstr "" msgid "Payment Interval" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Status" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2843,7 +2726,7 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Range" msgstr "" @@ -3267,11 +3150,6 @@ msgstr "" msgid "Set Password" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Settings saved successfully." @@ -3601,7 +3479,7 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "" @@ -3701,7 +3579,6 @@ msgid "Unlinking scheduled" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -3906,7 +3783,7 @@ msgid "Yearly Interval - Joining Cycle Included" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4129,11 +4006,6 @@ msgstr "" msgid "updated" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "without %{name}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4218,22 +4090,284 @@ msgstr "" msgid "No address" msgstr "" -#: lib/mv_web/live/member_live/index/payment_aging.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "%{count} unpaid" -msgstr "" - +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Active / former" msgstr "" #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Group" msgstr "" +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Payment" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{label} — open payment history" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Add filter" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "All (incl. former)" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Available filters" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Exited" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter by …" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former only" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fully paid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 1" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 2" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 3" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Joined" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 30 days" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Membership" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Payment status" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period from" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period to" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Quick" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Remove filter: %{label}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This month" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "≥ %{n} unpaid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} open" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "+%{count} more" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "All paid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "April" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "August" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "December" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "February" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees · %{range}" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "January" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "July" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "June" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "March" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "November" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "October" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "September" +msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for member details" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "No open cycles for this period" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Payment · %{range}" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Suspended: %{date}" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Unpaid until %{date}" +#~ msgstr "" diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs deleted file mode 100644 index b93acccb..00000000 --- a/test/mv_web/components/member_filter_component_test.exs +++ /dev/null @@ -1,660 +0,0 @@ -defmodule MvWeb.Components.MemberFilterComponentTest do - @moduledoc """ - Unit tests for the MemberFilterComponent. - - Tests cover: - - Rendering Payment Filter and Boolean Custom Fields - - Boolean filter selection and event emission - - Button label and badge logic - - Filtering to show only boolean custom fields - """ - # Kept async: false. The deferrable-FK migration removed the concurrent - # create_member deadlock, but this file additionally showed an async-isolation - # failure under load (filtered members from a parallel test leaking in), so it - # is not trivially async-safe; resolving that is a separate follow-up. - use MvWeb.ConnCase, async: false - - use Gettext, backend: MvWeb.Gettext - - import Phoenix.LiveViewTest - - alias Mv.Membership.CustomField - - # Helper to create a boolean custom field (uses system_actor - only admin can create) - defp create_boolean_custom_field(attrs \\ %{}) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - default_attrs = %{ - name: "test_boolean_#{System.unique_integer([:positive])}", - value_type: :boolean - } - - attrs = Map.merge(default_attrs, attrs) - - CustomField - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) - end - - # Helper to create a non-boolean custom field (uses system_actor - only admin can create) - defp create_string_custom_field(attrs \\ %{}) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - default_attrs = %{ - name: "test_string_#{System.unique_integer([:positive])}", - value_type: :string - } - - attrs = Map.merge(default_attrs, attrs) - - CustomField - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) - end - - describe "rendering" do - test "trigger carries a trailing chevron affordance", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # Mirror the shared dropdown affordance: a trailing chevron inside the - # bespoke filter trigger button. - chevron = - html - |> LazyHTML.from_fragment() - |> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down)) - - assert Enum.count(chevron) == 1 - end - - test "renders boolean custom fields when present", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Active Member"}) - - {:ok, view, _html} = live(conn, "/members") - - # Should show the boolean custom field name in the dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - assert html =~ boolean_field.name - end - - test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - _boolean_field = create_boolean_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - # Should have both "Payments" and "Custom Fields" group labels - assert html =~ gettext("Payments") || html =~ "Payment" - assert html =~ gettext("Individual datafields") - end - - test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - # Create a non-boolean field to ensure it's not shown - _string_field = create_string_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Component should exist with correct ID - assert has_element?(view, "#member-filter") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - - # Should show payment filter options (check both English and translated) - assert html =~ "All" || html =~ gettext("All") - assert html =~ "Paid" || html =~ gettext("Paid") - assert html =~ "Unpaid" || html =~ gettext("Unpaid") - - # Should not show any boolean field names (since none exist) - # We can't easily check this without knowing field names, but the structure should be correct - end - end - - describe "boolean filter selection" do - test "selecting boolean filter sends correct event", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Newsletter"}) - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Select "True" option for the boolean field using radio input - # Radio inputs trigger phx-change on the form, so we use render_change on the form - view - |> form("#member-filter form", %{ - "custom_boolean" => %{to_string(boolean_field.id) => "true"} - }) - |> render_change() - - # The event should be sent to the parent LiveView - # We verify this by checking that the URL is updated - assert_patch(view) - end - - test "payment filter still works after component extension", %{conn: conn} do - conn = conn_with_oidc_user(conn) - _boolean_field = create_boolean_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Select "Paid" option using radio input - # Radio inputs trigger phx-change on the form, so we use render_change on the form - view - |> form("#member-filter form", %{"payment_filter" => "paid"}) - |> render_change() - - # URL should be updated with cycle_status_filter=paid - path = assert_patch(view) - assert path =~ "cycle_status_filter=paid" - end - end - - describe "button label" do - test "shows active boolean filter names in button label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field1 = create_boolean_custom_field(%{name: "Active Member"}) - boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"}) - - # Set filters via URL - {:ok, view, _html} = - live( - conn, - "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" - ) - - # Component should exist - assert has_element?(view, "#member-filter") - - # Button label should contain the custom field names - # The exact format depends on implementation, but should show active filters - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name - end - - test "truncates long custom field names in button label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - # Create field with very long name (>30 characters) - long_name = String.duplicate("A", 50) - boolean_field = create_boolean_custom_field(%{name: long_name}) - - # Set filter via URL - {:ok, view, _html} = - live(conn, "/members?bf_#{boolean_field.id}=true") - - # Component should exist - assert has_element?(view, "#member-filter") - - # Get button label text - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # Button label should be truncated - full name should NOT appear in button - # (it may appear in dropdown when opened, but not in the button label itself) - # Check that button doesn't contain the full 50-character name - refute button_html =~ long_name - - # 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 - test "shows total count of active boolean filters in badge", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) - boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) - - # Set two filters via URL - {:ok, view, _html} = - live( - conn, - "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" - ) - - # Component should exist - assert has_element?(view, "#member-filter") - - # Badge should be visible when boolean filters are active - assert has_element?(view, "#member-filter .badge") - - # Badge should show count of active boolean filters (2 in this case) - badge_html = - view - |> element("#member-filter .badge") - |> render() - - assert badge_html =~ "2" - end - end - - describe "filtering" do - test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Boolean Field"}) - _string_field = create_string_custom_field(%{name: "String Field"}) - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Should show boolean field in the dropdown panel - # Extract only the dropdown panel HTML to check - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - # Should show boolean field in dropdown - assert dropdown_html =~ boolean_field.name - - # Should not show string field name in the filter dropdown - # (String fields should not appear in boolean filter section) - 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 - # `
` `