From 8303a39041beb0ef5f564bcd7c45d9a4b2d9c353 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:15 +0200 Subject: [PATCH] feat(overview): rework filter builder with editable chips and native date ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editable filter chips, uniformly-sized value controls, working field-picker search, and native date-range inputs with relative presets in place of the vendored calendar — native inputs give year-jump, manual entry and keyboard accessibility for free. --- assets/css/app.css | 15 + assets/js/app.js | 7 + assets/js/hooks/filter_combobox_search.js | 33 + assets/js/hooks/hover_popover.js | 76 ++ .../add_filter_builder_component.ex | 1003 +++++++++++------ lib/mv_web/live/member_live/index.ex | 44 + lib/mv_web/live/member_live/index.html.heex | 34 +- .../live/member_live/index/date_presets.ex | 2 +- .../member_live/index/filter_descriptor.ex | 7 +- .../live/member_live/index/payment_aging.ex | 26 +- priv/gettext/de/LC_MESSAGES/default.po | 208 +++- priv/gettext/default.pot | 129 ++- priv/gettext/en/LC_MESSAGES/default.po | 254 +++-- .../member_live/index_active_former_test.exs | 92 ++ .../member_live/index_chip_edit_test.exs | 91 ++ .../member_live/index_date_range_test.exs | 74 ++ .../member_live/index_filter_row_test.exs | 48 + .../index_membership_fee_status_test.exs | 34 +- .../member_live/index_payment_period_test.exs | 14 + .../member_live/index_payment_wiring_test.exs | 1 + test/mv_web/member_live/index_test.exs | 4 +- .../member_live/index_value_control_test.exs | 257 +++++ test/support/filter_builder_helpers.ex | 17 +- 23 files changed, 1905 insertions(+), 565 deletions(-) create mode 100644 assets/js/hooks/filter_combobox_search.js create mode 100644 assets/js/hooks/hover_popover.js create mode 100644 test/mv_web/member_live/index_chip_edit_test.exs create mode 100644 test/mv_web/member_live/index_date_range_test.exs create mode 100644 test/mv_web/member_live/index_filter_row_test.exs create mode 100644 test/mv_web/member_live/index_value_control_test.exs diff --git a/assets/css/app.css b/assets/css/app.css index 37091909..f8553c55 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -941,6 +941,21 @@ pointer-events: none; } +/* + * Payment-badge tooltip. A native Popover-API element (opened by the + * HoverPopover hook), so it lives in the browser TOP LAYER and is not clipped by + * the members table's overflow. Placement is pure CSS anchor positioning: the + * badge carries `anchor-name`, this popover carries `position-anchor`, and + * `position-area` drops it just below the badge, left-aligned. A `position-try` + * fallback flips it above the badge when there is no room below. No JS + * coordinate math is involved (unlike the older SortTooltip). + */ +.payment-tip { + margin: 0; + position-area: bottom span-right; + position-try-fallbacks: flip-block; +} + /* * Vertically center the row checkbox in the sticky first column using the * table-native vertical-align: middle. This keeps display: table-cell intact so diff --git a/assets/js/app.js b/assets/js/app.js index 90e36102..04842115 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,6 +22,8 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import Sortable from "../vendor/sortable" +import FilterComboboxSearch from "./hooks/filter_combobox_search" +import HoverPopover from "./hooks/hover_popover" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") @@ -36,6 +38,11 @@ function getBrowserTimezone() { // Hooks for LiveView components let Hooks = {} +// Add-filter builder hooks (member overview field-picker search). +Hooks.FilterComboboxSearch = FilterComboboxSearch +// Top-layer hover/focus popover (member overview payment badge tooltip). +Hooks.HoverPopover = HoverPopover + // IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS // property, not an HTML attribute, so it cannot be set from the server render. // Mirror it from the data-indeterminate attribute on mount and every update diff --git a/assets/js/hooks/filter_combobox_search.js b/assets/js/hooks/filter_combobox_search.js new file mode 100644 index 00000000..ec60061b --- /dev/null +++ b/assets/js/hooks/filter_combobox_search.js @@ -0,0 +1,33 @@ +// FilterComboboxSearch hook: the type-ahead search input of the add-filter +// field picker (§1.21). +// +// Responsibilities: +// * Autofocus the input when the picker opens, so the user can type +// immediately (the cursor lands in the search field on mount). Programmatic +// .focus() does NOT trigger the browser's :focus-visible state, so no focus +// ring is drawn on open — the ring appears only during keyboard navigation, +// which is exactly the desired behaviour (focus-visible on keyboard only). +// * Guard Enter: while the listbox is open, Enter must not submit the +// surrounding form (this replaces the old standalone ComboBox hook). +// +// The actual field-list filtering is done server-side (phx-change pushes the +// query; the component narrows the descriptor list), so this hook only manages +// focus and the Enter guard. +const FilterComboboxSearch = { + mounted() { + // Defer to the next frame so focus lands after the dropdown is laid out. + requestAnimationFrame(() => this.el.focus()) + + this.handleKeyDown = (e) => { + const open = this.el.getAttribute("aria-expanded") === "true" + if (e.key === "Enter" && open) e.preventDefault() + } + this.el.addEventListener("keydown", this.handleKeyDown) + }, + + destroyed() { + this.el.removeEventListener("keydown", this.handleKeyDown) + } +} + +export default FilterComboboxSearch diff --git a/assets/js/hooks/hover_popover.js b/assets/js/hooks/hover_popover.js new file mode 100644 index 00000000..71a80cdc --- /dev/null +++ b/assets/js/hooks/hover_popover.js @@ -0,0 +1,76 @@ +// HoverPopover hook: opens a native Popover-API element on hover/keyboard focus +// of its trigger, so the popover renders in the browser TOP LAYER and escapes +// any ancestor `overflow` clipping (the members table forces overflow-y:auto, +// which clips an absolutely-positioned daisyUI tooltip/dropdown-content). Unlike +// the older SortTooltip, placement is handed to CSS anchor positioning +// (`anchor-name` on the trigger, `position-anchor`/`position-area` on the +// popover) — this hook does no coordinate math, it only toggles visibility. +// +// WCAG 2.2 AA / SC 1.4.13 (Content on Hover or Focus): the popover is shown on +// both hover and focus, is dismissible via Escape, and is hoverable/persistent — +// a short close delay plus the popover's own hover keep it open while the pointer +// travels from the trigger onto the popover content. +// +// Expected DOM: +// +//
+const HoverPopover = { + mounted() { + this.tip = document.getElementById(this.el.dataset.popoverTarget) + // Feature-detect: without Popover API support fall back to no visual popover + // (the trigger's aria-label still conveys the summary to assistive tech). + if (!this.tip || typeof this.tip.showPopover !== "function") return + + this.open = () => { + try { + if (!this.tip.matches(":popover-open")) this.tip.showPopover() + } catch (_e) {} + } + this.close = () => { + try { + if (this.tip.matches(":popover-open")) this.tip.hidePopover() + } catch (_e) {} + } + this.cancelClose = () => { + if (this.closeTimer) { + clearTimeout(this.closeTimer) + this.closeTimer = null + } + } + this.show = () => { + this.cancelClose() + this.open() + } + this.scheduleClose = () => { + this.cancelClose() + this.closeTimer = setTimeout(() => this.close(), 120) + } + this.onKey = (e) => { + if (e.key === "Escape") this.close() + } + + this.el.addEventListener("mouseenter", this.show) + this.el.addEventListener("focus", this.show) + this.el.addEventListener("mouseleave", this.scheduleClose) + this.el.addEventListener("blur", this.scheduleClose) + this.el.addEventListener("keydown", this.onKey) + // Keep it open while the pointer is over the popover itself (hoverable). + this.tip.addEventListener("mouseenter", this.show) + this.tip.addEventListener("mouseleave", this.scheduleClose) + }, + + destroyed() { + if (!this.tip) return + this.cancelClose() + this.close() + this.el.removeEventListener("mouseenter", this.show) + this.el.removeEventListener("focus", this.show) + this.el.removeEventListener("mouseleave", this.scheduleClose) + this.el.removeEventListener("blur", this.scheduleClose) + this.el.removeEventListener("keydown", this.onKey) + this.tip.removeEventListener("mouseenter", this.show) + this.tip.removeEventListener("mouseleave", this.scheduleClose) + } +} + +export default HoverPopover diff --git a/lib/mv_web/live/components/add_filter_builder_component.ex b/lib/mv_web/live/components/add_filter_builder_component.ex index 35fb3dbb..ccafd01e 100644 --- a/lib/mv_web/live/components/add_filter_builder_component.ex +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -6,15 +6,21 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do 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`); + Quick / Membership / Custom fields (see `FilterDescriptor`), with a + focused, type-ahead search input (§1.21); * 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. + control** wrapped in a consistent popover shell titled "Filter: " + (§3.11); simple selects/radios commit on click, date-ranges have + Apply/Cancel (§3.13); + * applied filters render as **one compact chip per value**; the chip body is + an editable button that re-opens the value control pre-filled (§1.18) and a + separate × removes the filter; + * the "+ Add filter" trigger, the chips, and **Clear all** share one uniform + button size (§1.19). + + There is no "All"/"Alle" option in the value controls — a cleared filter is + simply the absence of a chip (§1.20). Multi-select group/fee-type values are + OR (`is`) / exclude-all (`is not`) (§1.29). 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: @@ -24,16 +30,19 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do * `{:boolean_filter_changed, id_str, true | false | nil}` * `{:date_filters_changed, date_filters}` - plus the period-scoped payment messages (§3.3): + plus the period-scoped payment and status messages: - * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3}}` + * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}` * `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}` + * `{:suspended_changed, boolean()}` + * `{:stichtag_changed, Date.t() | nil}` and `{:clear_all_filters}` for Clear all. """ use MvWeb, :live_component alias Mv.Constants + alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.DatePresets alias MvWeb.MemberLive.Index.FilterDescriptor @@ -50,6 +59,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do socket |> assign(:open_picker, false) |> assign(:picker_query, "") + |> assign(:edit_anchor, nil) |> assign(:pending, nil)} end @@ -80,6 +90,8 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) |> assign(:payment_filter, assigns[:payment_filter]) |> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period()) + |> assign(:suspended, assigns[:suspended] || false) + |> assign(:stichtag, assigns[:stichtag]) end # -------------------------------------------------------------------------- @@ -106,7 +118,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
<%!-- Field picker: an anchored dropdown (absolute, top-layer via z-index) @@ -126,24 +138,33 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do role="dialog" aria-label={gettext("Add filter")} > - ; a bare input + raises "form events require the input to be inside a form" and the live + filtering silently no-ops (§1.21). Hence the wrapping form. --%> +
+ class="mb-2 shrink-0" + data-testid="field-search-form" + > + +
      -
    • - - {chip.label} - <%!-- Subtle remove control: a ≥24px hit target (WCAG 2.5.8) wrapping a - small glyph whose only hover feedback is a minimal rounded tint around - the × itself, so the interaction stays quiet and no tooltip is used. --%> +
    • + <%!-- Editable chip: a join of a body button (re-opens the value control + pre-filled, §1.18) and a separate × remove button. Both are btn-sm so + the chip is the same height as the "+ Add filter" trigger and "Clear + all" (§1.19), and both are real, focusable buttons. --%> + + <%!-- border-l-0: the body and the × each carry a 1px outline border, + which doubles up at the join seam and sub-pixel-rounds to 1px on some + chips and 2px on others (flipping on browser zoom / DPR). Dropping the + ×'s left border leaves a single hairline seam on every chip. --%> + + + <%!-- Editing this chip (§1.18): its type-aware value control opens as an + anchored dropdown directly under THIS chip, in place — not as a new + pending chip elsewhere in the row. --%> +
      + {render_value_control(assigns)} +
    - <%!-- 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). --%> -
    + <%!-- Pending filter (add-flow only): the newly 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. Editing an existing chip anchors under that chip instead (above). + Clicking the chip (or Escape / click-away) dismisses without committing. --%> +
    @@ -234,159 +281,272 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
    """ end + # --- unified popover shell (§3.11) ---------------------------------------- + + attr :title, :string, required: true + attr :wide, :boolean, default: false + slot :inner_block, required: true + + # Shared popover shell for every value control (§3.11): the same container, + # the same "Filter: " title, and a consistent min-width (wider for the + # date-range and multi-select controls). The control's own body — a + # `phx-change="commit"` form or the date-range disclosure — carries the + # `value-control-*` test id so tests target the element that also owns the + # change binding. + defp control_shell(assigns) do + ~H""" +
    +
    + {gettext("Filter: %{field}", field: @title)} +
    + {render_slot(@inner_block)} +
    + """ + end + + # --- shared native date-range row ----------------------------------------- + + attr :from_name, :string, required: true + attr :to_name, :string, required: true + attr :from_value, :string, default: "" + attr :to_value, :string, default: "" + attr :testid, :string, default: nil + + # The single native "Von/Bis" range used by *every* date-range value control + # (payment period, join date, exit date, custom date fields). Two + # `` sit side by side in a non-wrapping `flex` row, each + # wrapped in `min-w-0 flex-1` so they share the row width equally and always + # stay on one line. Only the input `name`s differ between call sites, so the + # dispatch stays per-control while the render is identical everywhere. + defp date_range_row(assigns) do + ~H""" +
    +
    + <.input + type="date" + name={@from_name} + label={gettext("From")} + class="input input-sm input-bordered w-full" + value={@from_value} + /> +
    +
    + <.input + type="date" + name={@to_name} + label={gettext("To")} + class="input input-sm input-bordered w-full" + value={@to_value} + /> +
    +
    + """ + end + # --- value controls ------------------------------------------------------- defp render_value_control(%{pending: %{control: :payment_count}} = assigns) do ~H""" -
    -
    - {gettext("Payment")} - +
    + {gettext("Payment status")} +
    + <.toggle_option name="pay" value="fully_paid" checked={@payment_filter == :fully_paid}> + {gettext("All paid")} + + <.toggle_option name="pay" value="has_unpaid" checked={has_unpaid?(@payment_filter)}> + {gettext("Open contributions")} + +
    +
    + +
    - - - - - - -
    - <.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])} - /> - + {gettext("Open cycles")} +
    + <.input + type="number" + name="pay_min" + label={gettext("min")} + min="1" + class="input input-sm input-bordered w-20" + value={range_min(@payment_filter)} + /> + <.input + type="number" + name="pay_max" + label={gettext("max")} + min="1" + class="input input-sm input-bordered w-20" + value={range_max(@payment_filter)} + /> +
    +
    + + + +
    + {gettext("Contribution period")} + <.date_range_row + from_name="pay_from" + to_name="pay_to" + from_value={date_iso(@payment_period[:from]) || ""} + to_value={date_iso(@payment_period[:to]) || ""} + testid="payment-period-range" + /> +
    + + """ end defp render_value_control(%{pending: %{control: :active_former}} = assigns) do ~H""" -
    -
    - {gettext("Active / former")} -
    - - - + <.control_shell title={@pending.label} wide={true}> + +
    + {gettext("Active / former")} +
    + <.toggle_option name="quick" value="active" checked={quick(@date_filters) == :active}> + {gettext("Active")} + + <.toggle_option name="quick" value="former" checked={quick(@date_filters) == :former}> + {gettext("Former")} + + <.toggle_option name="quick" value="all" checked={quick(@date_filters) == :all}> + {gettext("All")} + +
    +
    + + <%!-- Separate paragraph (not part of the Active/Former button-join): the + point-in-time "active on X" Stichtag filter (§1.24). --%> +
    + <.input + type="date" + name="stichtag" + label={gettext("Active on reference date")} + class="input input-sm input-bordered" + value={date_iso(@stichtag)} + data-testid="stichtag-input" + />
    -
    -
    + + + <%!-- Third paragraph "Eigener Zeitraum": the same native From/To range the + join-date builder uses, wired to the exit_date custom mode — members whose + exit date falls within [from, to] (§1.24). A sibling form (not the + quick/Stichtag one) so committing a bound only touches the exit_date + slice and never re-reads the quick radios. --%> +
    + {gettext("Custom range")} + <.date_range_row + from_name="ed_from" + to_name="ed_to" + from_value={exit_bound_iso(@date_filters, :from)} + to_value={exit_bound_iso(@date_filters, :to)} + testid="exit-custom-range" + /> +
    + """ 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))} - /> -
    -
    -
    +
    + {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))} - /> -
    -
    -
    +
    + {ft.name} +
    + <.in_not_in_radios + name={"#{@fee_type_filter_prefix}#{ft.id}"} + current={Map.get(@fee_type_filters, to_string(ft.id))} + /> +
    +
    + + """ end @@ -394,91 +554,31 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do assigns = assign(assigns, :field_id, to_string(key)) ~H""" -
    -
    - {@pending.label} -
    - -
    +
    + + """ end @@ -493,46 +593,47 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do |> 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} - /> -
    -
    -
    + <.control_shell title={@pending.label} wide={true}> +
    + +
    + + <%!-- Currently applied concrete range, locally formatted (dd.MM.yyyy). --%> +

    + {local_range(@from_val, @to_val)} +

    + + <%!-- Separate "Eigener Zeitraum" paragraph: two native + controls side by side (§3.12 native-first — no cally). Committing a bound + through the shared date dispatch applies the custom range; the browser's + own date popup gives year-jump, manual entry and WCAG a11y for free. --%> +
    + {gettext("Custom range")} + <.date_range_row + from_name={@from_name} + to_name={@to_name} + from_value={@from_val} + to_value={@to_val} + testid="custom-range" + /> +
    + """ end @@ -559,7 +660,11 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do 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)} + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:edit_anchor, nil) + |> assign(:pending, nil)} end @impl true @@ -575,6 +680,23 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do socket |> assign(:open_picker, false) |> assign(:picker_query, "") + |> assign(:edit_anchor, nil) + |> assign(:pending, descriptor)} + end + + @impl true + def handle_event("edit_chip", %{"key" => key, "kind" => kind, "id" => id}, socket) do + # Editable chip (§1.18): re-open the field's value control pre-filled with the + # current state (the controls read straight from assigns, so no extra plumbing + # is needed to pre-fill them). Anchor the popover under the clicked chip by + # tracking its {kind, id}, so it opens in place rather than as a new pending + # chip at the row start. + descriptor = Enum.find(descriptors(socket.assigns), &(to_string(&1.key) == key)) + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:edit_anchor, {kind, id}) |> assign(:pending, descriptor)} end @@ -600,6 +722,8 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do def handle_event("commit", params, socket) do dispatch_payment(socket, params) dispatch_quick(socket, params) + dispatch_stichtag(socket, params) + dispatch_exit_range(socket, params) dispatch_prefix( socket, @@ -620,7 +744,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do dispatch_boolean(socket, params) dispatch_dates(socket, params) - {:noreply, assign(socket, :pending, nil)} + {:noreply, socket} end @impl true @@ -632,27 +756,56 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do @impl true def handle_event("clear_all", _params, socket) do send(self(), {:clear_all_filters}) - {:noreply, socket |> assign(:open_picker, false) |> assign(:pending, nil)} + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:edit_anchor, nil) + |> assign(:pending, nil)} end # --- dispatch helpers ----------------------------------------------------- - defp dispatch_payment(socket, params) do - case Map.get(params, "pay") do - nil -> - :ok + # The payment value control carries a hidden `__payment` marker so its status, + # count-range, suspended flag and period are dispatched independently (each + # sub-control commits its own slice; none depends on another being set). + defp dispatch_payment(socket, %{"__payment" => _} = params) do + dispatch_payment_filter(socket, params) + dispatch_suspended(socket, params) + dispatch_payment_period(socket, params) + end - value -> - filter = PaymentAging.parse_filter(value) - maybe_send(filter != socket.assigns.payment_filter, {:payment_filter_changed, filter}) + defp dispatch_payment(_socket, _params), do: :ok - period = %{ - from: parse_date(Map.get(params, "pay_from")), - to: parse_date(Map.get(params, "pay_to")) - } + defp dispatch_payment_filter(socket, params) do + filter = + case Map.get(params, "pay") do + "fully_paid" -> + :fully_paid - maybe_send(period != socket.assigns.payment_period, {:payment_period_changed, period}) - end + "has_unpaid" -> + {:unpaid_range, parse_count(Map.get(params, "pay_min"), 1), + parse_count(Map.get(params, "pay_max"), nil)} + + _ -> + nil + end + + maybe_send(filter != socket.assigns.payment_filter, {:payment_filter_changed, filter}) + end + + defp dispatch_suspended(socket, params) do + suspended = Map.get(params, "suspended") == "1" + maybe_send(suspended != socket.assigns.suspended, {:suspended_changed, suspended}) + end + + defp dispatch_payment_period(socket, params) do + 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 defp dispatch_quick(socket, params) do @@ -671,6 +824,31 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do end end + defp dispatch_stichtag(socket, params) do + if Map.has_key?(params, "stichtag") do + date = parse_date(Map.get(params, "stichtag")) + maybe_send(date != socket.assigns.stichtag, {:stichtag_changed, date}) + end + end + + # The exit-date "Eigener Zeitraum" range writes only the exit_date slice + # (mode :custom + bounds), preserving join_date and custom-date entries — so + # editing the exit range never wipes another active date filter. Committing an + # empty range simply parks exit_date in :custom with nil bounds (no chip, no + # DB predicate), matching the join-date control's empty-range behaviour. + defp dispatch_exit_range(socket, params) do + if Map.has_key?(params, "ed_from") or Map.has_key?(params, "ed_to") do + new_filters = + Map.put(socket.assigns.date_filters, :exit_date, %{ + mode: :custom, + from: parse_date(Map.get(params, "ed_from")), + to: parse_date(Map.get(params, "ed_to")) + }) + + maybe_send(new_filters != socket.assigns.date_filters, {:date_filters_changed, new_filters}) + 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) @@ -720,9 +898,12 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do end end + # The exit-date range is dispatched separately (dispatch_exit_range/2) so it + # only touches the exit_date slice; from_params/2 here rebuilds the whole date + # map, so ed_* params are intentionally excluded from this trigger. defp has_date_param?(params) do Enum.any?(params, fn {k, _} -> - k in ["ed_mode", "ed_from", "ed_to", "jd_from", "jd_to"] or + k in ["jd_from", "jd_to"] or String.starts_with?(k, @custom_date_filter_prefix) end) end @@ -735,6 +916,9 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do send(self(), {:payment_period_changed, PaymentAging.default_period()}) end + defp remove_chip("suspended", _id, _socket), do: send(self(), {:suspended_changed, false}) + defp remove_chip("stichtag", _id, _socket), do: send(self(), {:stichtag_changed, nil}) + 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}) @@ -749,6 +933,11 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do defp maybe_send(true, message), do: send(self(), message) defp maybe_send(false, _message), do: :ok + # Whether `chip` is the one currently being edited (§1.18): the edit anchor is + # the clicked chip's {kind, id}, uniquely identifying it in the row. + defp editing_chip?({kind, id}, %{kind: kind, id: id}), do: true + defp editing_chip?(_anchor, _chip), do: false + # -------------------------------------------------------------------------- # Chips # -------------------------------------------------------------------------- @@ -758,7 +947,9 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do fee_type_chips(assigns) ++ boolean_chips(assigns) ++ date_chips(assigns) ++ - payment_chips(assigns) + stichtag_chip(assigns) ++ + payment_chips(assigns) ++ + suspended_chip(assigns) end defp group_chips(assigns) do @@ -771,7 +962,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do :not_in -> gettext("Group: not %{name}", name: Map.get(names, id, id)) end - %{kind: "group", id: id, label: label} + %{kind: "group", id: id, edit_key: "group", label: label} end end @@ -785,7 +976,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do :not_in -> gettext("Fee type: not %{name}", name: Map.get(names, id, id)) end - %{kind: "fee_type", id: id, label: label} + %{kind: "fee_type", id: id, edit_key: "fee_type", label: label} end end @@ -794,7 +985,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do 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}"} + %{kind: "boolean", id: id, edit_key: id, label: "#{Map.get(names, id, id)}: #{state}"} end end @@ -810,15 +1001,35 @@ defmodule MvWeb.Components.AddFilterBuilderComponent 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)")}] + :active -> + [] + + :former -> + [%{kind: "date", id: "exit_date", edit_key: "active_former", label: gettext("Former")}] + + :custom -> + range_chip( + "date", + "exit_date", + "active_former", + gettext("Exited"), + Map.get(filters, :exit_date) + ) + + :all -> + [ + %{ + kind: "date", + id: "exit_date", + edit_key: "active_former", + 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)) + range_chip("date", to_string(field), to_string(field), prefix, Map.get(filters, field)) end defp custom_date_chips(assigns) do @@ -827,34 +1038,58 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do assigns.date_filters |> Enum.flat_map(fn {k, %{} = bounds} when is_binary(k) -> - range_chip("date", k, Map.get(names, k, k), bounds) + range_chip("date", k, k, Map.get(names, k, k), bounds) _ -> [] end) end - defp range_chip(kind, id, prefix, %{} = bounds) do + defp range_chip(kind, id, edit_key, 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) || "…"}"}] + [ + %{ + kind: kind, + id: id, + edit_key: edit_key, + label: "#{prefix}: #{local_range(date_iso(from), date_iso(to))}" + } + ] else [] end end - defp range_chip(_kind, _id, _prefix, _), do: [] + defp range_chip(_kind, _id, _edit_key, _prefix, _), do: [] defp payment_chips(assigns) do filter_chip = case assigns.payment_filter do :fully_paid -> - [%{kind: "payment", id: "payment", label: gettext("Fully paid")}] + [%{kind: "payment", id: "payment", edit_key: "payment", label: gettext("All paid")}] {:has_unpaid, n} -> - [%{kind: "payment", id: "payment", label: gettext("≥ %{n} unpaid", n: n)}] + [ + %{ + kind: "payment", + id: "payment", + edit_key: "payment", + label: gettext("≥ %{n} unpaid", n: n) + } + ] + + {:unpaid_range, min, max} -> + [ + %{ + kind: "payment", + id: "payment", + edit_key: "payment", + label: unpaid_range_label(min, max) + } + ] _ -> [] @@ -865,9 +1100,9 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do period_chip = if period[:from] || period[:to] do label = - "#{gettext("Period")}: #{date_iso(period[:from]) || "…"}–#{date_iso(period[:to]) || "…"}" + "#{gettext("Contribution period")}: #{local_range(date_iso(period[:from]), date_iso(period[:to]))}" - [%{kind: "payment_period", id: "payment_period", label: label}] + [%{kind: "payment_period", id: "payment_period", edit_key: "payment", label: label}] else [] end @@ -875,6 +1110,31 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do filter_chip ++ period_chip end + defp suspended_chip(%{suspended: true}), + do: [ + %{kind: "suspended", id: "suspended", edit_key: "payment", label: gettext("Suspended fees")} + ] + + defp suspended_chip(_), do: [] + + defp stichtag_chip(%{stichtag: %Date{} = date}) do + [ + %{ + kind: "stichtag", + id: "stichtag", + edit_key: "active_former", + label: gettext("Active on %{date}", date: DateFormatter.format_date(date)) + } + ] + end + + defp stichtag_chip(_), do: [] + + defp unpaid_range_label(min, nil) when min <= 1, do: gettext("Open contributions") + defp unpaid_range_label(min, nil), do: gettext("≥ %{n} unpaid", n: min) + defp unpaid_range_label(min, max) when min == max, do: gettext("%{n} unpaid", n: min) + defp unpaid_range_label(min, max), do: gettext("%{min}–%{max} unpaid", min: min, max: max) + # -------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------- @@ -902,19 +1162,43 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do attr :name, :string, required: true attr :current, :atom, default: nil + # No "All" option (§1.20): a cleared filter is simply the absence of a chip. + # Multi-select is OR for `:in` and exclude-all for `:not_in` (§1.29). Uses the + # same check/x Ja/Nein join-toggle as the boolean custom-field control so the + # two-state operator reads consistently: "Ja" = member is in this value + # (`:in`), "Nein" = member is not (`:not_in`). defp in_not_in_radios(assigns) do ~H""" - - -
    @@ -471,7 +473,7 @@ /> <% 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) %> @@ -506,7 +516,8 @@ id={tip_id} popover="manual" role="tooltip" - class="m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl" + style={"position-anchor: #{anchor}"} + class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl" data-testid="payment-tooltip" >
      @@ -521,7 +532,8 @@
    • 0} class="flex"> - {gettext("+%{count} more", count: overflow)} + <.icon name="hero-plus-circle" class="size-3.5" /> + {gettext("%{count} more", count: overflow)}
    diff --git a/lib/mv_web/live/member_live/index/date_presets.ex b/lib/mv_web/live/member_live/index/date_presets.ex index ca671f73..dd886c94 100644 --- a/lib/mv_web/live/member_live/index/date_presets.ex +++ b/lib/mv_web/live/member_live/index/date_presets.ex @@ -39,7 +39,7 @@ defmodule MvWeb.MemberLive.Index.DatePresets do Returns every preset key in display order (rolling windows first, then the period-to-date presets), for rendering the preset radio list. """ - @spec all() :: [preset()] + @spec all() :: [preset(), ...] def all, do: @presets @doc """ 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 0e80dc5a..9cbde461 100644 --- a/lib/mv_web/live/member_live/index/filter_descriptor.ex +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -101,7 +101,12 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do maybe_fee_type(fee_types) ++ [ %{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range}, - %{key: :exit_date, group: :membership, label: gettext("Exit date"), control: :exit_date} + %{ + key: :exit_date, + group: :membership, + label: gettext("Exit date"), + control: :active_former + } ] end 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 9fc4cd65..4d6576e1 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -81,7 +81,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do (at least N unpaid cycles in the period) * anything else → `nil` (no payment-count filter) """ - @spec parse_filter(term()) :: filter() + @spec parse_filter(term()) :: nil | :fully_paid | {:has_unpaid, 1..3} def parse_filter("fully_paid"), do: :fully_paid def parse_filter("unpaid_1"), do: {:has_unpaid, 1} def parse_filter("unpaid_2"), do: {:has_unpaid, 2} @@ -212,17 +212,31 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do @doc """ Human-readable tooltip naming the active payment period with locally formatted - (`dd.MM.yyyy`) bounds (§1.28). The all-outstanding default names all - outstanding cycles; open-ended periods render an open bound as an ellipsis. + (`dd.MM.yyyy`) bounds (§1.28). Explains that the shown figures refer to the + filtered contribution period, then names it: the all-outstanding default + names all outstanding cycles; open-ended periods render an open bound as an + ellipsis. """ @spec period_tooltip(period()) :: String.t() - def period_tooltip(%{from: nil, to: nil}), do: gettext("Fees across all outstanding cycles") + def period_tooltip(%{from: nil, to: nil}), + do: + gettext( + "The values refer to the filtered contribution period. Fees across all outstanding cycles" + ) def period_tooltip(%{from: from, to: to}) do - gettext("Fees in period %{from}–%{to}", from: period_bound(from), to: period_bound(to)) + gettext( + "The values refer to the filtered contribution period. Fees in period %{from}–%{to}", + from: period_bound(from), + to: period_bound(to) + ) end - def period_tooltip(_), do: gettext("Fees across all outstanding cycles") + def period_tooltip(_), + do: + gettext( + "The values refer to the filtered contribution period. Fees across all outstanding cycles" + ) defp period_bound(%Date{} = d), do: DateFormatter.format_date(d) defp period_bound(_), do: "…" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d6f9123f..1a8682ad 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -89,11 +89,6 @@ msgstr "Aktionen" msgid "Active members" msgstr "Aktive Mitglieder" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Active only" -msgstr "Nur aktive" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -1244,7 +1239,6 @@ msgstr "Beispiele" msgid "Exit Date" msgstr "Austrittsdatum" -#: 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 @@ -2725,11 +2719,6 @@ msgstr "Vierteljährlich" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "Zeitraum" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Reactivate member" @@ -4102,7 +4091,6 @@ msgstr "Aktiv / ehemalig" msgid "Group" 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 msgid "Payment" @@ -4168,16 +4156,6 @@ msgstr "Filtern nach …" 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}" @@ -4188,21 +4166,6 @@ msgstr "Gruppe: %{name}" 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" @@ -4223,21 +4186,6 @@ msgstr "Mitgliedschaft" 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" @@ -4268,11 +4216,7 @@ msgstr "≥ %{n} offen" 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/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format msgid "All paid" @@ -4363,12 +4307,152 @@ msgstr "Letzte 7 Tage" msgid "This quarter" msgstr "Dieses Quartal" -#: lib/mv_web/live/member_live/index/payment_aging.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format -msgid "Fees across all outstanding cycles" -msgstr "Beiträge über alle offenen Zyklen" +msgid "%{min}–%{max} unpaid" +msgstr "%{min}–%{max} offen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "%{n} unpaid" +msgstr "%{n} offen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active on %{date}" +msgstr "Aktiv am %{date}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active on reference date" +msgstr "Aktiv am Stichtag" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter: %{field}" +msgstr "Filter: %{field}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Open cycles" +msgstr "Offene Zyklen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Suspended fees" +msgstr "Ausgesetzte Beiträge" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "max" +msgstr "max" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "min" +msgstr "min" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom range" +msgstr "Eigener Zeitraum" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} more" +msgstr "%{count} weitere" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Open contributions" +msgstr "offene Beiträge" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Contribution period" +msgstr "Beitragszeitraum" #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format -msgid "Fees in period %{from}–%{to}" -msgstr "Beiträge im Zeitraum %{from}–%{to}" +msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles" +msgstr "Die Angaben beziehen sich auf den gefilterten Beitragszeitraum. Beiträge über alle offenen Zyklen" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}" +msgstr "Die Angaben beziehen sich auf den gefilterten Beitragszeitraum. Beiträge im Zeitraum %{from}–%{to}" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Active only" +#~ msgstr "Nur aktive" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Apply" +#~ msgstr "Übernehmen" + +#~ #: lib/mv_web/live/member_live/index/payment_aging.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Fees across all outstanding cycles" +#~ msgstr "Beiträge über alle offenen Zyklen" + +#~ #: lib/mv_web/live/member_live/index/payment_aging.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Fees in period %{from}–%{to}" +#~ msgstr "Beiträge im Zeitraum %{from}–%{to}" + +#~ #: 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 "Has unpaid" +#~ msgstr "Hat offene" + +#~ #: 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 ≥ 3" +#~ msgstr "Offen ≥ 3" + +#~ #: 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 "Range" +#~ msgstr "Zeitraum" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "is" +#~ msgstr "ist" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "is not" +#~ msgstr "ist nicht" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d0f9425b..ae7e899b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -90,11 +90,6 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Active only" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -1245,7 +1240,6 @@ msgstr "" msgid "Exit Date" msgstr "" -#: 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 @@ -2726,11 +2720,6 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Reactivate member" @@ -4102,7 +4091,6 @@ 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" @@ -4168,16 +4156,6 @@ msgstr "" 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}" @@ -4188,21 +4166,6 @@ msgstr "" 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" @@ -4223,21 +4186,6 @@ msgstr "" 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" @@ -4268,11 +4216,7 @@ msgstr "" 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/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format msgid "All paid" @@ -4363,12 +4307,77 @@ msgstr "" msgid "This quarter" msgstr "" -#: lib/mv_web/live/member_live/index/payment_aging.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format -msgid "Fees across all outstanding cycles" +msgid "%{min}–%{max} unpaid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "%{n} unpaid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active on %{date}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active on reference date" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter: %{field}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Open cycles" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Suspended fees" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "max" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "min" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom range" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} more" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Open contributions" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Contribution period" msgstr "" #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format -msgid "Fees in period %{from}–%{to}" +msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 427fb998..9f43b72f 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -90,11 +90,6 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Active only" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Add Member" @@ -1245,7 +1240,6 @@ msgstr "" msgid "Exit Date" msgstr "" -#: 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 @@ -2726,11 +2720,6 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Reactivate member" @@ -3956,6 +3945,7 @@ msgid "email %{email} has already been taken" msgstr "" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "" @@ -4101,7 +4091,6 @@ 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" @@ -4167,16 +4156,6 @@ msgstr "" 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}" @@ -4187,21 +4166,6 @@ msgstr "" 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" @@ -4212,11 +4176,6 @@ msgstr "" 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" @@ -4227,21 +4186,6 @@ msgstr "" 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" @@ -4272,11 +4216,7 @@ msgstr "" 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/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format, fuzzy msgid "All paid" @@ -4307,11 +4247,6 @@ msgstr "" 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" @@ -4347,11 +4282,166 @@ msgstr "" msgid "September" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click to sort by open payment cycles" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 12 months" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 3 months" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Last 7 days" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This quarter" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "%{min}–%{max} unpaid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{n} unpaid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active on %{date}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active on reference date" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter: %{field}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Open cycles" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Suspended fees" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "max" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "min" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom range" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} more" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Open contributions" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Contribution period" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}" +msgstr "" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Active only" +#~ msgstr "" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Apply" +#~ 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/payment_aging.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Fees across all outstanding cycles" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index/payment_aging.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Fees in period %{from}–%{to}" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Fees · %{range}" +#~ 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, fuzzy +#~ msgid "Has unpaid" +#~ 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 ≥ 3" +#~ msgstr "" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Last year" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/index.html.heex #~ #, elixir-autogen, elixir-format #~ msgid "No open cycles for this period" @@ -4362,6 +4452,26 @@ msgstr "" #~ msgid "Payment · %{range}" #~ 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 "Range" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/index.html.heex #~ #, elixir-autogen, elixir-format #~ msgid "Suspended: %{date}" @@ -4371,3 +4481,13 @@ msgstr "" #~ #, elixir-autogen, elixir-format #~ msgid "Unpaid until %{date}" #~ msgstr "" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "is" +#~ msgstr "" + +#~ #: lib/mv_web/live/components/add_filter_builder_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "is not" +#~ msgstr "" diff --git a/test/mv_web/member_live/index_active_former_test.exs b/test/mv_web/member_live/index_active_former_test.exs index 55093117..4bb2c731 100644 --- a/test/mv_web/member_live/index_active_former_test.exs +++ b/test/mv_web/member_live/index_active_former_test.exs @@ -61,4 +61,96 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do assert has_element?(view, "#row-#{ctx.future.id}") assert has_element?(view, "#row-#{ctx.former.id}") end + + describe "Stichtag paragraph (§1.24)" do + test "the active/former control carries a separate 'active on reference date' input", ctx do + {:ok, view, _html} = live(ctx.conn, ~p"/members") + + pick_field(view, "active_former") + + # The Stichtag input sits in its own paragraph, not inside the + # Active/Former button-join. + assert has_element?(view, "[data-testid='stichtag-paragraph'] input[name='stichtag']") + refute has_element?(view, ".join input[name='stichtag']") + end + + test "setting a reference date activates the point-in-time membership filter", ctx do + {:ok, view, _html} = live(ctx.conn, ~p"/members") + + pick_field(view, "active_former") + + view + |> element("[data-testid='value-control-active-former']") + |> render_change(%{"quick" => "active", "stichtag" => "2024-06-15"}) + + _ = render(view) + path = assert_patch(view) + assert path =~ "stichtag=2024-06-15" + end + end + + describe "Eigener Zeitraum exit-date range (§1.24)" do + test "the exit-date control carries a separate native From/To range paragraph", ctx do + {:ok, view, _html} = live(ctx.conn, ~p"/members") + + pick_field(view, "active_former") + + # Third paragraph, same native two-input range control the join-date + # builder uses — separate from the Active/Former join and the Stichtag box. + assert has_element?(view, "[data-testid='value-control-exit-range']") + + assert has_element?( + view, + "[data-testid='value-control-exit-range'] input[type='date'][name='ed_from']" + ) + + assert has_element?( + view, + "[data-testid='value-control-exit-range'] input[type='date'][name='ed_to']" + ) + end + + test "typing an exit-date range commits ed_mode=custom with bounds and filters to it", ctx do + from = Date.add(Date.utc_today(), -60) + to = Date.add(Date.utc_today(), -1) + + {:ok, view, _html} = live(ctx.conn, ~p"/members") + + pick_field(view, "active_former") + + view + |> element("[data-testid='value-control-exit-range']") + |> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)}) + + _ = render(view) + path = assert_patch(view) + assert path =~ "ed_mode=custom" + assert path =~ "ed_from=#{Date.to_iso8601(from)}" + assert path =~ "ed_to=#{Date.to_iso8601(to)}" + + # Only the member whose exit date falls within [from, to] is shown. + assert has_element?(view, "#row-#{ctx.former.id}") + refute has_element?(view, "#row-#{ctx.active.id}") + refute has_element?(view, "#row-#{ctx.future.id}") + end + + test "an exit-date range preserves an existing join-date filter", ctx do + from = Date.add(Date.utc_today(), -60) + to = Date.add(Date.utc_today(), -1) + + {:ok, view, _html} = live(ctx.conn, ~p"/members?jd_from=2020-01-01") + + pick_field(view, "active_former") + + view + |> element("[data-testid='value-control-exit-range']") + |> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)}) + + _ = render(view) + path = assert_patch(view) + # The exit range must not wipe the pre-existing join-date bound. + assert path =~ "jd_from=2020-01-01" + assert path =~ "ed_mode=custom" + end + end end diff --git a/test/mv_web/member_live/index_chip_edit_test.exs b/test/mv_web/member_live/index_chip_edit_test.exs new file mode 100644 index 00000000..a79dc675 --- /dev/null +++ b/test/mv_web/member_live/index_chip_edit_test.exs @@ -0,0 +1,91 @@ +defmodule MvWeb.MemberLive.IndexChipEditTest do + @moduledoc """ + §1.18 — clicking an applied-filter chip body re-opens that filter's value + control pre-filled with the current value; a changed selection re-commits and + updates the chip; the × still removes the filter. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Membership.Group + + setup %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Edit", last_name: "Member", email: "edit@example.com"}, + actor: actor + ) + + {:ok, group} = + Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor) + + {:ok, _mg} = + Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor) + + %{conn: conn_with_oidc_user(conn), group: group} + end + + test "clicking the chip body opens the value control pre-filled with the current value", %{ + conn: conn, + group: group + } do + {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in") + + refute has_element?(view, "[data-testid='value-control-group']") + + view |> element("[data-testid='filter-chip-edit']") |> render_click() + + # The group control opens with this group's "is" (in) operator pre-selected. + assert has_element?(view, "[data-testid='value-control-group']") + assert has_element?(view, "input[name='group_#{group.id}'][value='in'][checked]") + end + + test "the value control opens anchored under the edited chip, not as a new pending chip", %{ + conn: conn, + group: group + } do + {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in") + + view |> element("[data-testid='filter-chip-edit']") |> render_click() + + # §1.18: the control is a descendant of the chip's own list item (anchored in + # place under that chip), and no separate pending chip is spun up at the row. + assert has_element?( + view, + "[data-testid='filter-chip-item'] [data-testid='value-control-group']" + ) + + refute has_element?(view, "[data-testid='pending-chip']") + refute has_element?(view, "[data-testid='pending-filter']") + end + + test "changing the value from the re-opened control re-commits and updates the URL", %{ + conn: conn, + group: group + } do + {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in") + + view |> element("[data-testid='filter-chip-edit']") |> render_click() + + view + |> element("[data-testid='value-control-group']") + |> render_change(%{"group_#{group.id}" => "not_in"}) + + _ = render(view) + path = assert_patch(view) + assert path =~ "group_#{group.id}=not_in" + end + + test "the × still removes the filter", %{conn: conn, group: group} do + {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in") + + view |> element("[data-testid='filter-chip-remove']") |> render_click() + + path = assert_patch(view) + refute path =~ "group_#{group.id}" + refute has_element?(view, "[data-testid='filter-chip']") + end +end diff --git a/test/mv_web/member_live/index_date_range_test.exs b/test/mv_web/member_live/index_date_range_test.exs new file mode 100644 index 00000000..30df18f4 --- /dev/null +++ b/test/mv_web/member_live/index_date_range_test.exs @@ -0,0 +1,74 @@ +defmodule MvWeb.MemberLive.IndexDateRangeTest do + @moduledoc """ + §1.25 / §3.12 (native-first) — the date-range value control combines the + relative presets with a separate "Eigener Zeitraum" (custom range) paragraph + holding two native `` fields (Von/Bis). cally is removed. + + Picking a preset commits a concrete range; typing into the native Von/Bis + inputs commits the custom range through the shared date-filter dispatch. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + setup %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, _member} = + Mv.Membership.create_member( + %{first_name: "Range", last_name: "Member", email: "range@example.com"}, + actor: actor + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "the join-date control shows presets and native Von/Bis date inputs", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "join_date") + + assert has_element?(view, "[data-testid='value-control-date-range']") + assert has_element?(view, "[data-testid='date-presets']") + # Separate "Eigener Zeitraum" paragraph with two native date inputs side by side. + assert has_element?(view, "[data-testid='custom-range']") + assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_from']") + assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_to']") + end + + test "cally is fully removed from the date-range control", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "join_date") + + refute has_element?(view, "[data-testid='date-range-cally']") + refute has_element?(view, "calendar-range") + refute has_element?(view, "[phx-hook='DateRangeCally']") + end + + test "picking a preset commits a concrete range to the URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "join_date") + view |> element("[data-testid='date-preset-this_year']") |> render_click() + + path = assert_patch(view) + today = Date.utc_today() + assert path =~ "jd_from=#{today.year}-01-01" + assert path =~ "jd_to=#{Date.to_iso8601(today)}" + end + + test "typing a native custom range commits it to the URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "join_date") + + view + |> element("[data-testid='value-control-date-range']") + |> render_change(%{"jd_from" => "2024-03-01", "jd_to" => "2024-03-31"}) + + path = assert_patch(view) + assert path =~ "jd_from=2024-03-01" + assert path =~ "jd_to=2024-03-31" + end +end diff --git a/test/mv_web/member_live/index_filter_row_test.exs b/test/mv_web/member_live/index_filter_row_test.exs new file mode 100644 index 00000000..a4593465 --- /dev/null +++ b/test/mv_web/member_live/index_filter_row_test.exs @@ -0,0 +1,48 @@ +defmodule MvWeb.MemberLive.IndexFilterRowTest do + @moduledoc """ + §1.19 — the filter row renders "+ Add filter", the applied-filter chips and + "Clear all" as one uniform button size (all share the `btn-sm` sizing), and + each chip is composed of real, focusable buttons. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Membership.Group + + setup %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Row", last_name: "Member", email: "row@example.com"}, + actor: actor + ) + + {:ok, group} = + Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor) + + {:ok, _mg} = + Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor) + + %{conn: conn_with_oidc_user(conn), group: group} + end + + test "add-filter trigger, chips and clear-all share the uniform btn-sm size", %{ + conn: conn, + group: group + } do + {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in") + + assert has_element?(view, "[data-testid='add-filter-trigger'].btn-sm") + assert has_element?(view, "[data-testid='clear-all-filters'].btn-sm") + # The chip body and its remove control are real, focusable buttons at btn-sm. + assert has_element?(view, "button[data-testid='filter-chip-edit'].btn-sm") + assert has_element?(view, "button[data-testid='filter-chip-remove'].btn-sm") + + # The join seam must be a single hairline: the remove button drops its own + # left border so the body's right border is the only one at the seam (no + # doubled 1px border that sub-pixel-rounds to 1px/2px and flips on zoom). + assert has_element?(view, "button[data-testid='filter-chip-remove'].border-l-0") + end +end diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index 4dd92c73..ae6e5657 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -57,7 +57,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do assert html =~ "Paid" end - test "the badge is a focusable control that drills into the member fees history", %{ + test "the badge is a real navigation link that drills into the member fees history", %{ conn: conn } do fee_type = create_fee_type(%{interval: :yearly}) @@ -66,13 +66,33 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do {:ok, view, _html} = live(conn, "/members") - assert has_element?( - view, - "button[data-testid='payment-badge']##{"payment-badge-#{member.id}"}" - ) + badge = "a[data-testid='payment-badge']#payment-badge-#{member.id}" + href = "/members/#{member.id}?tab=membership_fees" - # A tooltip popover lists the open cycles for the period. - assert has_element?(view, "##{"payment-tip-#{member.id}"}[popover]") + # A semantic navigating to the member's membership-fees section — not a + # div with JS.navigate — so it is keyboard- and right-click-friendly. + assert has_element?(view, "#{badge}[href='#{href}']") + + # Its hover/focus tooltip is a top-layer native Popover opened via the + # HoverPopover hook (no SortTooltip id-linking on the badge anymore). + assert has_element?(view, "#{badge}[phx-hook='HoverPopover']") + assert has_element?(view, "#{badge}[data-popover-target='payment-tip-#{member.id}']") + refute has_element?(view, "[data-testid='payment-badge'][data-tooltip-id]") + + # The tooltip popover lists the open cycles for the period. + assert has_element?(view, "#payment-tip-#{member.id}[popover]") + end + + test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do + fee_type = create_fee_type(%{interval: :yearly}) + member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id}) + create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid}) + + {:ok, view, _html} = live(conn, "/members") + + assert has_element?(view, "a[data-testid='payment-badge']#payment-badge-#{member.id}") + refute has_element?(view, "#payment-tip-#{member.id}") + refute has_element?(view, "a#payment-badge-#{member.id}[phx-hook]") end test "members without cycles render without error", %{conn: conn} do diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs index 5f1977ac..be256316 100644 --- a/test/mv_web/member_live/index_payment_period_test.exs +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -75,6 +75,20 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do tip = PaymentAging.period_tooltip(%{from: nil, to: nil}) assert is_binary(tip) and tip =~ "outstanding" end + + test "explains that the values refer to the filtered contribution period" do + # The header badge falls back to a plain "filtered" label whenever no + # compact short code fits (arbitrary or open-ended ranges); its tooltip + # must spell out what "filtered" means, not just repeat the range. + for period <- [ + %{from: nil, to: nil}, + %{from: ~D[2026-02-03], to: ~D[2026-08-17]}, + %{from: ~D[2026-01-01], to: nil} + ] do + tip = PaymentAging.period_tooltip(period) + assert tip =~ "refer to the filtered contribution period" + end + end end describe "payment-count filter codec" do diff --git a/test/mv_web/member_live/index_payment_wiring_test.exs b/test/mv_web/member_live/index_payment_wiring_test.exs index 8c7b0547..1186cbbd 100644 --- a/test/mv_web/member_live/index_payment_wiring_test.exs +++ b/test/mv_web/member_live/index_payment_wiring_test.exs @@ -88,6 +88,7 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do badge = view |> element("[data-testid='payment-period-badge']") |> render() assert badge =~ "03.02.2026" assert badge =~ "17.08.2026" + assert badge =~ "refer to the filtered contribution period" end end end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index d8946529..3e3b3d3a 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1411,9 +1411,9 @@ defmodule MvWeb.MemberLive.IndexTest do ) # Both filters are reflected as chips: the boolean field at "Yes" and the - # period-scoped payment filter as "Fully paid". + # period-scoped payment filter as "All paid". assert boolean_chip?(view, boolean_field, "Yes") - assert render(view) =~ "Fully paid" + assert render(view) =~ "All paid" # Both should be in URL when triggering search view diff --git a/test/mv_web/member_live/index_value_control_test.exs b/test/mv_web/member_live/index_value_control_test.exs new file mode 100644 index 00000000..4c92b948 --- /dev/null +++ b/test/mv_web/member_live/index_value_control_test.exs @@ -0,0 +1,257 @@ +defmodule MvWeb.MemberLive.IndexValueControlTest do + @moduledoc """ + §1.20 / §1.21 / §1.22 / §1.23 — the reworked value controls of the add-filter + builder: + + * no "All"/"Alle" option in the boolean / group value controls (§1.20); + * the field picker's search input is a live type-ahead (§1.21); + * the payment control reveals a two-sided open-cycle count range under + "Has unpaid" and offers a suspended-status option (§1.22, §1.23). + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Membership.CustomField + alias Mv.Membership.Group + + setup %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, _member} = + Mv.Membership.create_member( + %{first_name: "Val", last_name: "Member", email: "val@example.com"}, + actor: actor + ) + + {:ok, group} = + Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor) + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter_#{System.unique_integer([:positive])}", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + %{conn: conn_with_oidc_user(conn), group: group, field: field} + end + + describe "unified popover shell (§3.11)" do + test "every value control shares the 'Filter: ' shell title", %{ + conn: conn, + field: field + } do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "group") + assert has_element?(view, "[data-testid='control-title']", "Filter:") + + pick_field(view, field.id) + assert has_element?(view, "[data-testid='control-title']", "Filter:") + + pick_field(view, "payment") + assert has_element?(view, "[data-testid='control-title']", "Filter:") + end + end + + describe "no All option (§1.20)" do + test "the group control offers is / is-not but no 'all' option", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "group") + + assert has_element?(view, "[data-testid='value-control-group'] input[value='in']") + assert has_element?(view, "[data-testid='value-control-group'] input[value='not_in']") + refute has_element?(view, "[data-testid='value-control-group'] input[value='all']") + end + + test "the group operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "group") + + # Same join-toggle presentation as the boolean custom-field control: a + # check icon on the ":in" (Ja) option and an x-mark on ":not_in" (Nein), + # with a visible selected state. + assert has_element?(view, "[data-testid='value-control-group'] .join .hero-check-circle") + assert has_element?(view, "[data-testid='value-control-group'] .join .hero-x-circle") + + assert has_element?( + view, + "[data-testid='value-control-group'] label.has-\\[\\:checked\\]\\:btn-primary" + ) + end + + test "the fee-type operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do + _fee_type = Mv.Fixtures.create_fee_type() + + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "fee_type") + + assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-check-circle") + assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-x-circle") + end + + test "the boolean control offers Yes / No but no 'all' option", %{conn: conn, field: field} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, field.id) + + assert has_element?(view, "[data-testid='value-control-boolean'] input[value='true']") + assert has_element?(view, "[data-testid='value-control-boolean'] input[value='false']") + refute has_element?(view, "[data-testid='value-control-boolean'] input[value='all']") + end + + test "the boolean control renders a check-icon Yes and an x-icon No join toggle", %{ + conn: conn, + field: field + } do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, field.id) + + # Join-toggle with a check icon on Yes and an x-mark icon on No (Task 5). + assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-check-circle") + assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-x-circle") + # A checked option shows a visible selected state (btn-primary via has-[:checked]). + assert has_element?( + view, + "[data-testid='value-control-boolean'] label.has-\\[\\:checked\\]\\:btn-primary" + ) + end + end + + describe "field picker search (§1.21)" do + test "the search input is a live type-ahead hook and narrows the field list", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_picker(view) + + assert has_element?(view, "[data-testid='field-combobox'][phx-hook='FilterComboboxSearch']") + # The search input must live inside a form carrying the change binding — + # LiveView rejects `phx-change` on an input that is not inside a
    + # ("form events require the input to be inside a form"), which silently + # broke the live filtering in the browser (§1.21). + assert has_element?(view, "form[phx-change='filter_fields'] [data-testid='field-combobox']") + + # A non-matching query removes the Group option from the listbox. + view + |> form("[data-testid='field-search-form']", %{"picker_query" => "zzzznomatch"}) + |> render_change() + + refute has_element?(view, "#member-filter-field-opt-group") + end + end + + describe "payment status labels" do + test "the toggle reads 'All paid' / 'Open contributions'", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "payment") + + assert has_element?(view, "[data-testid='value-control-payment']", "All paid") + assert has_element?(view, "[data-testid='value-control-payment']", "Open contributions") + refute has_element?(view, "[data-testid='value-control-payment']", "Fully paid") + refute has_element?(view, "[data-testid='value-control-payment']", "Has unpaid") + end + + test "the applied fully-paid chip reads 'All paid'", %{conn: conn} do + {:ok, view, _html} = live(conn, "/members?pay_filter=fully_paid") + + assert has_element?(view, "[data-testid='filter-chip']", "All paid") + refute render(view) =~ "Fully paid" + end + end + + describe "payment control (§1.22, §1.23)" do + test "'Has unpaid' reveals the two-sided open-cycle count range", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "payment") + refute has_element?(view, "[data-testid='payment-count-range']") + + view + |> element("[data-testid='value-control-payment']") + |> render_change(%{ + "__payment" => "1", + "pay" => "has_unpaid", + "pay_min" => "1", + "pay_max" => "" + }) + + _ = render(view) + assert_patch(view) + + assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_min']") + assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_max']") + end + + test "the suspended option activates the suspended-status filter in the URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "payment") + assert has_element?(view, "[data-testid='payment-suspended'] input[name='suspended']") + + view + |> element("[data-testid='value-control-payment']") + |> render_change(%{"__payment" => "1", "suspended" => "1"}) + + _ = render(view) + path = assert_patch(view) + assert path =~ "suspended=1" + end + end + + describe "unified date-range controls" do + # Every date-range value control (payment period, join date, exit date, and + # custom date fields) renders the same shared native Von/Bis row: a + # non-wrapping flex row whose two `` are each wrapped in + # `min-w-0 flex-1` so they share width equally and stay on one line. + test "payment period, join, exit and custom date all render the shared row", %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, date_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday_#{System.unique_integer([:positive])}", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + {:ok, view, _html} = live(conn, ~p"/members") + + assert_shared_range(view, "payment", "payment-period-range", "pay_from", "pay_to") + assert_shared_range(view, "join_date", "custom-range", "jd_from", "jd_to") + assert_shared_range(view, "active_former", "exit-custom-range", "ed_from", "ed_to") + + cd_from = "cdf_#{date_field.id}_from" + cd_to = "cdf_#{date_field.id}_to" + assert_shared_range(view, date_field.id, "custom-range", cd_from, cd_to) + end + end + + # Opens the given field's value control and asserts its date-range row is the + # shared native Von/Bis layout: a `flex` (non-wrapping) row whose two date + # inputs are each wrapped in `min-w-0 flex-1` (equal width, single line). + defp assert_shared_range(view, field, testid, from_name, to_name) do + pick_field(view, field) + + assert has_element?(view, "[data-testid='#{testid}'].flex") + refute has_element?(view, "[data-testid='#{testid}'].flex-wrap") + + assert has_element?( + view, + "[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{from_name}']" + ) + + assert has_element?( + view, + "[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{to_name}']" + ) + end +end diff --git a/test/support/filter_builder_helpers.ex b/test/support/filter_builder_helpers.ex index 54a88953..6e851c48 100644 --- a/test/support/filter_builder_helpers.ex +++ b/test/support/filter_builder_helpers.ex @@ -60,16 +60,23 @@ defmodule MvWeb.FilterBuilderHelpers do end @doc """ - Applies a payment-count filter (and optionally a period) through the builder. - `filter` is one of `"all"`, `"fully_paid"`, `"unpaid_1"`, `"unpaid_2"`, - `"unpaid_3"`; `from`/`to` are ISO-8601 strings or `""`. + Applies a payment status filter (and optionally a period) through the builder. + `status` is `"fully_paid"` or `"has_unpaid"`; `from`/`to` are ISO-8601 strings + or `""`. For `"has_unpaid"` the open-cycle range defaults to `min` 1 / no max. """ - def apply_payment_filter(view, filter, from \\ "", to \\ "") do + def apply_payment_filter(view, status, from \\ "", to \\ "", min \\ "1", max \\ "") do pick_field(view, "payment") view |> element("[data-testid='value-control-payment']") - |> render_change(%{"pay" => filter, "pay_from" => from, "pay_to" => to}) + |> render_change(%{ + "__payment" => "1", + "pay" => status, + "pay_min" => min, + "pay_max" => max, + "pay_from" => from, + "pay_to" => to + }) flush_and_patch(view) end