diff --git a/CHANGELOG.md b/CHANGELOG.md index f734002b..af124bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Members overview – view settings** – A new "View" dropdown switches the table between compact and comfortable density and controls how the Member and Address fields are shown: as combined composite cells or split into their underlying columns (Vorname/Nachname/E-Mail resp. Straße/PLZ/Ort). Every choice is remembered per browser. - **Members overview – reset columns to default** – The column-visibility menu gained a reset button that restores the curated default column set. +- **Members overview – add-filter builder** – A searchable, grouped field picker adds filters as editable chips instead of a fixed panel; the active filters are encoded in the URL, so a filtered view is bookmarkable and survives a reload. +- **Members overview – more filters** – New filters for members active on a chosen date (point-in-time), members with suspended cycles in the period, matching any of several selected groups (OR), and a range on the number of open payment cycles. +- **Members overview – payment aging column** – The fees column shows how many billing cycles each member has left unpaid in the selected period (cycles are member-relative), is sortable by that count, carries a period indicator on its header, and lists the open — and separately, the suspended — cycles in a hover tooltip. ### Changed - **Members overview – loads on demand** – The overview no longer reads every member into memory. Filtering, sorting and pagination now run in the database, and rows stream in via infinite scroll as you reach the bottom of the list, so large member lists open quickly. The exact number of matching members is announced (for example "250 Members"). - **Members overview – condensed name and address** – By default a member shows as a composite name cell and a two-line address cell (street / postal code + city); both columns are sortable and city and postal code stay findable via search. A clear (×) button was added to the search field. - **Members overview – bulk actions span the full result set** – Select-all, copy e-mail addresses, and export now act on the complete filtered set rather than only the rows currently loaded, and the count reflects the full filtered total. - **Members overview – sticky header, pinned column and sortable headers** – The table keeps a sticky header and pinned identifier column, sort headers that expose the active sort direction, boolean columns rendered as ✓/✗ icons, larger touch targets in both densities, and result-count and loading changes announced through a polite live region — meeting WCAG 2.2 AA for accessibility. +- **Members overview – filter panel replaced** – The vertical filter panel is replaced by the add-filter builder, and date ranges now use native date inputs with relative presets, with a quick year jump and manual entry. + +### Fixed +- **Members overview – keyboard-operable filter toggles** – The filter builder's Yes/No and status toggles can now be reached and switched with the keyboard and show a visible focus indicator (WCAG 2.1.1 Keyboard, 2.4.7 Focus Visible). ## [1.3.0] - 2026-06-16 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/membership/member.ex b/lib/membership/member.ex index 9f7965bb..b0ff298f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -717,6 +717,25 @@ defmodule Mv.Membership.Member do constraints one_of: [:unpaid, :paid, :suspended] end + calculate :unpaid_cycle_count, + :integer, + expr( + count(membership_fee_cycles, + query: [ + filter: + expr( + status == :unpaid and + (is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and + (is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to)) + ) + ] + ) + ) do + description "Count of the member's unpaid cycles whose cycle_end is within the given period" + argument :period_from, :date, allow_nil?: true + argument :period_to, :date, allow_nil?: true + end + calculate :overdue_count, :integer do description "Count of unpaid cycles that have already ended (cycle_end < today)" # Automatically load cycles with all attributes and membership_fee_type diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index cb881938..d6842efe 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -36,6 +36,16 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do postgres do table "membership_fee_cycles" repo Mv.Repo + + custom_indexes do + # Serves the period-scoped unpaid-cycle count on the member overview: + # count of a member's cycles with status = :unpaid and cycle_end in the + # active period. The composite (member_id, status, cycle_end) lets the + # aggregate seek the per-member unpaid slice and range-scan cycle_end + # without touching paid/suspended rows. + index [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + end end resource do diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 657aa9b7..a0607078 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -38,6 +38,20 @@ defmodule Mv.Constants do @custom_date_filter_prefix "cdf_" + @payment_period_from_param "pay_from" + + @payment_period_to_param "pay_to" + + @payment_filter_param "pay_filter" + + @payment_count_min_param "pay_min" + + @payment_count_max_param "pay_max" + + @suspended_param "suspended" + + @as_of_date_param "as_of_date" + @max_boolean_filters 50 @max_mailto_bulk_recipients 50 @@ -162,6 +176,81 @@ defmodule Mv.Constants do """ def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ + Returns the URL parameter name for the payment-aging period lower bound + (ISO-8601 date; scopes both the payment column and the payment filter). + + ## Examples + + iex> Mv.Constants.payment_period_from_param() + "pay_from" + """ + def payment_period_from_param, do: @payment_period_from_param + + @doc """ + Returns the URL parameter name for the payment-aging period upper bound. + + ## Examples + + iex> Mv.Constants.payment_period_to_param() + "pay_to" + """ + def payment_period_to_param, do: @payment_period_to_param + + @doc """ + Returns the URL parameter name for the payment-count filter + (`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`). + + ## Examples + + iex> Mv.Constants.payment_filter_param() + "pay_filter" + """ + def payment_filter_param, do: @payment_filter_param + + @doc """ + Returns the URL parameter name for the payment open-cycle count lower bound + (used with `pay_filter=has_unpaid` for the two-sided count range). + + ## Examples + + iex> Mv.Constants.payment_count_min_param() + "pay_min" + """ + def payment_count_min_param, do: @payment_count_min_param + + @doc """ + Returns the URL parameter name for the payment open-cycle count upper bound. + + ## Examples + + iex> Mv.Constants.payment_count_max_param() + "pay_max" + """ + def payment_count_max_param, do: @payment_count_max_param + + @doc """ + Returns the URL parameter name for the suspended-status filter flag. + + ## Examples + + iex> Mv.Constants.suspended_param() + "suspended" + """ + def suspended_param, do: @suspended_param + + @doc """ + Returns the URL parameter name for the point-in-time "active on X" membership + filter: keeps members who were active on the given ISO-8601 date X, i.e. + `join_date <= X and (exit_date is nil or exit_date > X)`. + + ## Examples + + iex> Mv.Constants.as_of_date_param() + "as_of_date" + """ + def as_of_date_param, do: @as_of_date_param + @doc """ Returns the maximum number of boolean custom field filters allowed per request. 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..4cc2c619 --- /dev/null +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -0,0 +1,1355 @@ +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`), with a + focused, type-ahead search input (§1.21); + * picking a field closes the picker and opens a focused, type-aware **value + 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: + + * `{: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 and status messages: + + * `{: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()}` + * `{:as_of_date_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 + 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(:edit_anchor, nil) + |> 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()) + |> assign(:suspended, assigns[:suspended] || false) + |> assign(:as_of_date, assigns[:as_of_date]) + 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 (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. --%> +
+ +
+ {render_value_control(assigns)} +
+
+ + +
+ """ + 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""" + <.control_shell title={@pending.label} wide={true}> +
+ +
+ {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")} + +
+
+ +
+ {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""" + <.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" as-of-date filter (§1.24). --%> +
+ <.input + type="date" + name="as_of_date" + label={gettext("Active on reference date")} + class="input input-sm input-bordered" + value={date_iso(@as_of_date)} + data-testid="as-of-date-input" + /> +
+
+ + <%!-- Third paragraph, a custom range: 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/as-of-date 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""" + <.control_shell title={@pending.label} wide={true}> +
+
+ {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""" + <.control_shell title={@pending.label} wide={true}> +
+
+ {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""" + <.control_shell title={@pending.label}> +
+
+ {@pending.label} +
+ <.toggle_option + name={"custom_boolean[#{@field_id}]"} + value="true" + icon="hero-check-circle" + checked={Map.get(@boolean_filters, @field_id) == true} + > + {gettext("Yes")} + + <.toggle_option + name={"custom_boolean[#{@field_id}]"} + value="false" + icon="hero-x-circle" + checked={Map.get(@boolean_filters, @field_id) == false} + > + {gettext("No")} + +
+
+
+ + """ + 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""" + <.control_shell title={@pending.label} wide={true}> +
+ +
+ + <%!-- Currently applied concrete range, locally formatted (dd.MM.yyyy). --%> +

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

+ + <%!-- Separate custom-range 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 + + 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(:edit_anchor, nil) + |> 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(: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 + + @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_as_of_date(socket, params) + dispatch_exit_range(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, socket} + 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(:edit_anchor, nil) + |> assign(:pending, nil)} + end + + # --- dispatch helpers ----------------------------------------------------- + + # 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 + + defp dispatch_payment(_socket, _params), do: :ok + + defp dispatch_payment_filter(socket, params) do + filter = + case Map.get(params, "pay") do + "fully_paid" -> + :fully_paid + + "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 + 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_as_of_date(socket, params) do + if Map.has_key?(params, "as_of_date") do + date = parse_date(Map.get(params, "as_of_date")) + maybe_send(date != socket.assigns.as_of_date, {:as_of_date_changed, date}) + end + end + + # The exit-date custom 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) + 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 + + # 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 ["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("suspended", _id, _socket), do: send(self(), {:suspended_changed, false}) + defp remove_chip("as_of_date", _id, _socket), do: send(self(), {:as_of_date_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}) + + 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 + + # 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 + # -------------------------------------------------------------------------- + + defp applied_chips(assigns) do + group_chips(assigns) ++ + fee_type_chips(assigns) ++ + boolean_chips(assigns) ++ + date_chips(assigns) ++ + as_of_date_chip(assigns) ++ + payment_chips(assigns) ++ + suspended_chip(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, edit_key: "group", 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, edit_key: "fee_type", 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, edit_key: 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", 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), 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, k, Map.get(names, k, k), bounds) + + _ -> + [] + end) + end + + defp range_chip(kind, id, edit_key, prefix, %{} = bounds) do + from = bounds[:from] + to = bounds[:to] + + if from || to do + [ + %{ + 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, _edit_key, _prefix, _), do: [] + + defp payment_chips(assigns) do + filter_chip = + case assigns.payment_filter do + :fully_paid -> + [%{kind: "payment", id: "payment", edit_key: "payment", label: gettext("All paid")}] + + {:has_unpaid, 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) + } + ] + + _ -> + [] + end + + period = assigns.payment_period || %{} + + period_chip = + if period[:from] || period[:to] do + label = + "#{gettext("Contribution period")}: #{local_range(date_iso(period[:from]), date_iso(period[:to]))}" + + [%{kind: "payment_period", id: "payment_period", edit_key: "payment", label: label}] + else + [] + end + + 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 as_of_date_chip(%{as_of_date: %Date{} = date}) do + [ + %{ + kind: "as_of_date", + id: "as_of_date", + edit_key: "active_former", + label: gettext("Active on %{date}", date: DateFormatter.format_date(date)) + } + ] + end + + defp as_of_date_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 + # -------------------------------------------------------------------------- + + 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 + + # 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""" + <.toggle_option name={@name} value="in" icon="hero-check-circle" checked={@current == :in}> + {gettext("Yes")} + + <.toggle_option + name={@name} + value="not_in" + icon="hero-x-circle" + checked={@current == :not_in} + > + {gettext("No")} + + """ + end + + attr :name, :string, required: true + attr :value, :string, required: true + attr :checked, :boolean, default: false + attr :icon, :string, default: nil + slot :inner_block, required: true + + # Shared two-state join toggle used by the boolean and is/is-not controls + # (Task 5): a `join-item` button label wrapping a native radio, with a visible + # selected state (`btn-primary` when the radio is checked) and an optional + # leading icon. Consistent look across every two-state selector. + # + # The radio is `sr-only` (visually hidden) rather than `hidden`/`display:none` + # so it stays keyboard-focusable and in the tab order: Tab reaches the group, + # native same-`name` radio semantics move the selection with the arrow keys + # (WCAG 2.1.1). `has-[:focus-visible]:*` surfaces a visible ring on the styled + # label while its input has keyboard focus (WCAG 2.4.7); `z-10` lifts the + # focused item so the ring is not clipped by adjacent join-items. + defp toggle_option(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 quick(filters), do: DateFilter.quick_state(filters) + + defp has_unpaid?({:has_unpaid, _}), do: true + defp has_unpaid?({:unpaid_range, _, _}), do: true + defp has_unpaid?(_), do: false + + defp range_min({:unpaid_range, min, _}), do: Integer.to_string(min) + defp range_min({:has_unpaid, n}), do: Integer.to_string(n) + defp range_min(_), do: "1" + + defp range_max({:unpaid_range, _, max}) when is_integer(max), do: Integer.to_string(max) + defp range_max(_), do: "" + + 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 + + # ISO string for a bound of the exit_date custom range, pre-filling the + # custom-range inputs only when the exit filter is actually in :custom + # mode (the quick active/former/all states carry no user range). + defp exit_bound_iso(filters, bound) do + case Map.get(filters, :exit_date, %{}) do + %{mode: :custom} = ed -> date_iso(Map.get(ed, bound)) || "" + _ -> "" + end + end + + # Locally formats an ISO from/to pair as "dd.MM.yyyy–dd.MM.yyyy" (either side + # may be blank), reusing the app-wide DateFormatter (§2.8). + defp local_range(from_iso, to_iso) do + "#{fmt_iso(from_iso)}–#{fmt_iso(to_iso)}" + end + + defp fmt_iso(iso) when is_binary(iso) and iso != "" do + case Date.from_iso8601(iso) do + {:ok, date} -> DateFormatter.format_date(date) + _ -> "…" + end + end + + defp fmt_iso(_), do: "…" + + defp parse_count(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {n, ""} when n > 0 -> n + _ -> default + end + end + + defp parse_count(_value, default), do: default + + # 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 + + defp date_for(filters, field, bound) do + case filters do + %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) + _ -> "" + end + end + + # --- date presets --------------------------------------------------------- + + defp date_presets do + Enum.map(DatePresets.all(), fn preset -> {Atom.to_string(preset), preset_label(preset)} end) + end + + defp preset_label(:last_7_days), do: gettext("Last 7 days") + defp preset_label(:last_30_days), do: gettext("Last 30 days") + defp preset_label(:last_3_months), do: gettext("Last 3 months") + defp preset_label(:last_12_months), do: gettext("Last 12 months") + defp preset_label(:this_month), do: gettext("This month") + defp preset_label(:this_quarter), do: gettext("This quarter") + defp preset_label(:this_year), do: gettext("This year") + + # Resolves a preset string (from `phx-value-preset`) to a concrete `{from, to}` + # tuple via the shared, clock-free `DatePresets` resolver, or nil for an + # unknown preset. The resolver defines "this year" as period-to-date + # ([Jan 1, today]) per §2.8, superseding the prior full-year range. + defp preset_range(preset) when is_binary(preset) do + case Enum.find(DatePresets.all(), &(Atom.to_string(&1) == preset)) do + nil -> + nil + + key -> + %{from: from, to: to} = DatePresets.resolve(key) + {from, to} + end + 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..62a82026 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 @@ -49,6 +49,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter + alias MvWeb.Helpers.MembershipFeeHelpers + alias MvWeb.MemberLive.Index.AsOfDate alias MvWeb.MemberLive.Index.CustomFieldValueLookup alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.ExportPayload @@ -58,6 +60,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 +177,10 @@ 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(:suspended, false) + |> assign(:as_of_date, nil) |> assign(:group_filters, %{}) |> assign(:groups, groups) |> assign(:fee_type_filters, %{}) @@ -203,8 +209,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 +313,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 +430,91 @@ 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 + ) + + new_path = ~p"/members?#{query_params}" + {: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(), + suspended: false, + as_of_date: nil, + group_filters: %{}, + fee_type_filters: %{}, + boolean_filters: %{}, + date_filters: DateFilter.default() + }}, + socket + ) + end + + @impl true + def handle_info({:suspended_changed, suspended}, socket) do + socket = + socket + |> assign(:suspended, suspended) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{suspended: suspended})) + |> 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({:as_of_date_changed, as_of_date}, socket) do + socket = + socket + |> assign(:as_of_date, as_of_date) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{as_of_date: as_of_date})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -573,11 +632,14 @@ 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, %{})) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) + |> assign(:suspended, Map.get(opts, :suspended, false)) + |> assign(:as_of_date, Map.get(opts, :as_of_date)) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() @@ -663,12 +725,14 @@ 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_suspended(params) + |> maybe_update_as_of_date(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 +776,12 @@ 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[:suspended], + socket.assigns[:as_of_date], 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 +932,30 @@ 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_status_params(base_params, opts[:suspended], opts[:as_of_date]) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) add_date_filters(base_params, opts.date_filters) end + # Suspended-status flag and as-of date serialize additively into the flat + # URL contract (suspended=1 / as_of_date=), omitted when inactive (§2.1). + defp add_status_params(params, suspended, as_of_date) do + params + |> Map.merge(PaymentAging.suspended_to_params(suspended)) + |> Map.merge(AsOfDate.to_params(as_of_date)) + 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 +965,11 @@ 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, + suspended: socket.assigns[:suspended] || false, + as_of_date: socket.assigns[:as_of_date], 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 +1271,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 +1392,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 +1427,10 @@ 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, + suspended: socket.assigns[:suspended] || false, + as_of_date: socket.assigns[:as_of_date], sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, custom_fields: socket.assigns.all_custom_fields @@ -1429,7 +1513,7 @@ defmodule MvWeb.MemberLive.Index do valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) or - field in [:groups, :membership_fee_type, :name, :address] + field in [:groups, :membership_fee_type, :name, :address, :payment] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do @@ -1439,6 +1523,7 @@ defmodule MvWeb.MemberLive.Index do field == "membership_fee_type" -> :membership_fee_type field == "name" -> :name field == "address" -> :address + field == "payment" -> :payment true -> safe_member_field_atom_only(field) end @@ -1488,9 +1573,10 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, nil), do: default # Computed/pseudo fields that are nonetheless sortable (they resolve to DB - # sort keys in OverviewQuery): groups aggregate and the composite Name/Address - # columns. Other computed fields (e.g. membership_fee_status) are not sortable. - @sortable_computed_fields [:groups, :name, :address] + # sort keys in OverviewQuery): groups aggregate, the composite Name/Address + # columns, and the period-scoped payment (unpaid-cycle-count) column. Other + # computed fields (e.g. membership_fee_status) are not sortable. + @sortable_computed_fields [:groups, :name, :address, :payment] defp determine_field(default, sf) when is_binary(sf) do sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1) @@ -1550,13 +1636,23 @@ 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)) + + # Suspended-status filter (§1.23) and point-in-time as-of date (§1.24/§2.6): + # both decoded straight from the URL so `handle_params` remains the single + # source of truth. Absent params fall back to no filter. + defp maybe_update_suspended(socket, params), + do: assign(socket, :suspended, PaymentAging.parse_suspended(params)) + + defp maybe_update_as_of_date(socket, params), + do: assign(socket, :as_of_date, AsOfDate.parse(params)) defp maybe_update_group_filters(socket, params) when is_map(params) do prefix = @group_filter_prefix @@ -1652,10 +1748,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 +1820,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 +1907,22 @@ 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(_period), do: gettext("Fees") + + @doc """ + Whether a payment period is actively scoped (at least one bound set), used to + decide whether the compact period indicator badge renders (§1.28). The + all-outstanding default (both bounds nil) shows no badge. + """ + def payment_period_active?(%{from: nil, to: nil}), do: false + def payment_period_active?(%{from: _, to: _}), do: true + def payment_period_active?(_), do: false + 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 +2042,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..39182330 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,11 @@ 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} + suspended={@suspended} + as_of_date={@as_of_date} /> - <.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 +106,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 +458,116 @@ <:col :let={member} :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} + sort_field={:payment} + label={ + ~H""" +
+ <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_payment} + field={:payment} + label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} + sort_hint={gettext("Click to sort by open payment cycles")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + <.badge + :if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)} + variant="primary" + size="sm" + class="font-normal shrink-0" + title={PaymentAging.period_tooltip(@payment_period)} + aria-label={PaymentAging.period_tooltip(@payment_period)} + data-testid="payment-period-badge" + > + {PaymentAging.period_short_code(@payment_period) || gettext("filtered")} + +
+ """ + } > - <%= 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}" %> + <% anchor = "--pay-#{member.id}" %> + <%!-- The badge is a real navigation link (semantic, keyboard- and + right-click-friendly) into the member's payment history. Its hover/focus + tooltip is a native Popover-API element in the top layer, placed via CSS + anchor positioning (see the `payment-tip` class + the HoverPopover hook), + so it escapes the members table's `overflow` clipping without any custom + coordinate math. No tooltip renders when the member is fully paid. --%> + <.link + navigate={~p"/members/#{member.id}?tab=membership_fees"} + id={"payment-badge-#{member.id}"} + class={["badge badge-soft badge-md", badge.color_class]} + style={count > 0 && "anchor-name: #{anchor}"} + phx-hook={count > 0 && "HoverPopover"} + data-popover-target={count > 0 && tip_id} + aria-details={count > 0 && tip_id} + aria-label={gettext("%{label} — open payment history", label: badge.label)} + data-testid="payment-badge" + > + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <%= if count > 0 do %> + <% %{unpaid: unpaid, suspended: suspended} = + PaymentAging.partition_open_cycles(member, @payment_period) %> + <% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %> + <% end %> <:col diff --git a/lib/mv_web/live/member_live/index/as_of_date.ex b/lib/mv_web/live/member_live/index/as_of_date.ex new file mode 100644 index 00000000..6ec9d9a1 --- /dev/null +++ b/lib/mv_web/live/member_live/index/as_of_date.ex @@ -0,0 +1,37 @@ +defmodule MvWeb.MemberLive.Index.AsOfDate do + @moduledoc """ + URL codec for the point-in-time "active on X" membership filter (§1.24/§2.6). + + The as-of date is a single ISO-8601 date X carried in the flat URL contract + under the `as_of_date` key. When set, the overview keeps members who were + active on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the + predicate itself lives in `OverviewQuery`. Absent or malformed values decode + to nil (no filter), and nil encodes to the empty map so a fresh URL stays + canonical. + """ + + @as_of_date_param Mv.Constants.as_of_date_param() + + @doc """ + Decodes the as-of date from a params map. Missing or malformed ISO-8601 + values yield nil. + """ + @spec parse(map()) :: Date.t() | nil + def parse(params) when is_map(params), do: parse_date(Map.get(params, @as_of_date_param)) + + @doc """ + Encodes an as-of date into URL params. nil yields the empty map. + """ + @spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()} + def to_params(%Date{} = date), do: %{@as_of_date_param => Date.to_iso8601(date)} + def to_params(_), do: %{} + + 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/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index b24c2f49..ca6ed704 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -67,6 +67,52 @@ defmodule MvWeb.MemberLive.Index.DateFilter do } end + @typedoc """ + The three-state active/former quick filter. It is not a stored field but a + view onto `exit_date.mode`: `:active` ↔ `:active_only`, `:former` ↔ + `:inactive_only`, `:all` ↔ `:all`. A detailed `:custom` exit-date selection + has no quick equivalent and surfaces as `:custom` (no quick chip selected). + """ + @type quick_state :: :active | :former | :all | :custom + + @quick_to_mode %{active: :active_only, former: :inactive_only, all: :all} + + @doc """ + Derives the active/former quick-filter state from the single `exit_date` + source. Total function; an absent or malformed `exit_date` reads as the + default `:active`. This is the only reader — there is no second state to keep + in sync (§2.4). + """ + @spec quick_state(map()) :: quick_state() + def quick_state(filters) when is_map(filters) do + case Map.get(filters, :exit_date, %{}) do + %{mode: :active_only} -> :active + %{mode: :inactive_only} -> :former + %{mode: :all} -> :all + %{mode: :custom} -> :custom + _ -> :active + end + end + + @doc """ + Applies an active/former quick-filter choice onto the date filter state by + writing the corresponding `exit_date` mode and clearing its bounds. Only the + `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) :: %{ + :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}) + end + @doc """ Decodes URL params into a date filter state map. diff --git a/lib/mv_web/live/member_live/index/date_presets.ex b/lib/mv_web/live/member_live/index/date_presets.ex new file mode 100644 index 00000000..dd886c94 --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_presets.ex @@ -0,0 +1,70 @@ +defmodule MvWeb.MemberLive.Index.DatePresets do + @moduledoc """ + Relative date-range presets for the date value controls (§1.25/§2.8). + + Each preset resolves against a reference date "today" into a concrete + `%{from: Date.t(), to: Date.t()}` range. Rolling "last N days/months" presets + end on today; the "this month/quarter/year" presets run period-to-date from + the respective period start up to today. Resolving is pure and clock-free — + the caller passes `today` (defaulting to `Date.utc_today/0`), so the resolved + bounds are testable and reproducible. + + The resolved bounds feed the same `jd_*` / `ed_*` / `cdf_*` URL params as an + absolute custom range; presets are purely an input convenience and add no new + persistence dimension. + """ + + @type preset :: + :last_7_days + | :last_30_days + | :last_3_months + | :last_12_months + | :this_month + | :this_quarter + | :this_year + + @type range :: %{from: Date.t(), to: Date.t()} + + @presets [ + :last_7_days, + :last_30_days, + :last_3_months, + :last_12_months, + :this_month, + :this_quarter, + :this_year + ] + + @doc """ + 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(), ...] + def all, do: @presets + + @doc """ + Whether `key` names a known preset. + """ + @spec preset?(term()) :: boolean() + def preset?(key), do: key in @presets + + @doc """ + Resolves a preset to a concrete `{from, to}` range relative to `today` + (default `Date.utc_today/0`). + """ + @spec resolve(preset(), Date.t()) :: range() + def resolve(preset, today \\ Date.utc_today()) + + def resolve(:last_7_days, today), do: %{from: Date.add(today, -6), to: today} + def resolve(:last_30_days, today), do: %{from: Date.add(today, -29), to: today} + def resolve(:last_3_months, today), do: %{from: Date.shift(today, month: -3), to: today} + def resolve(:last_12_months, today), do: %{from: Date.shift(today, month: -12), to: today} + def resolve(:this_month, today), do: %{from: %{today | day: 1}, to: today} + + def resolve(:this_quarter, today) do + first_month = div(today.month - 1, 3) * 3 + 1 + %{from: Date.new!(today.year, first_month, 1), to: today} + end + + def resolve(:this_year, today), do: %{from: Date.new!(today.year, 1, 1), to: today} +end 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 new file mode 100644 index 00000000..9cbde461 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -0,0 +1,145 @@ +defmodule MvWeb.MemberLive.Index.FilterDescriptor do + @moduledoc """ + Data-driven catalog of the filter fields offered by the member-overview + add-filter builder. + + Each descriptor is a plain, serializable map describing one pickable field: + + %{ + key: atom() | String.t(), # stable identifier (custom fields use their UUID string) + group: :quick | :membership | :custom_fields, + label: String.t(), # human-readable, gettext-translated + control: atom() # which type-aware value control the builder renders + } + + The catalog is the persistence basis the saved-views work (#549) docks onto: + it is derived purely from the field context (groups, fee types, custom fields) + and carries no UI or query state. + + Grouping (confirmed taxonomy): + + * `:quick` — Payment, Active/former (common shortcuts) + * `:membership` — Group, Fee type, Join date, Exit date + * `:custom_fields` — one entry per filterable club-defined custom field + (boolean and date fields; other value types are not filtered on the + overview) + + A group with no descriptors is omitted entirely (no header) — see + `visible_groups/1`. + """ + + use Gettext, backend: MvWeb.Gettext + + @type group :: :quick | :membership | :custom_fields + + @type t :: %{ + key: atom() | String.t(), + group: group(), + label: String.t(), + control: atom() + } + + # Fixed display order of the groups in the picker. + @group_order [:quick, :membership, :custom_fields] + + # Custom-field value types the overview can filter on. + @filterable_custom_field_types [:boolean, :date] + + @doc """ + Returns the ordered list of all descriptors available for the given field + context. Recognised context keys (all optional, default empty): + + * `:groups` — list of `%{id: _, name: _}` group structs + * `:fee_types` — list of fee-type structs + * `:custom_fields` — list of custom-field structs (`:value_type`, `:name`, `:id`) + """ + @spec all(map()) :: [t()] + def all(context) when is_map(context) do + quick() ++ + membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++ + custom_fields(Map.get(context, :custom_fields, [])) + end + + @doc "The group an individual descriptor belongs to." + @spec group_for(t()) :: group() + def group_for(%{group: group}), do: group + + @doc "The fixed display order of the picker groups." + @spec group_order() :: [group(), ...] + def group_order, do: @group_order + + @doc """ + Returns `[{group, [descriptor]}]` in display order, including only groups that + have at least one descriptor. Groups with no available fields are omitted so + the picker renders no empty header (§1.10). + """ + @spec visible_groups([t()]) :: [{group(), [t()]}] + def visible_groups(descriptors) when is_list(descriptors) do + grouped = Enum.group_by(descriptors, & &1.group) + + @group_order + |> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end) + |> Enum.reject(fn {_group, ds} -> ds == [] end) + end + + # --- static groups -------------------------------------------------------- + + defp quick do + [ + %{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count}, + %{ + key: :active_former, + group: :quick, + label: gettext("Active / former"), + control: :active_former + } + ] + end + + defp membership(groups, fee_types) do + maybe_group(groups) ++ + 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: :active_former + } + ] + end + + defp maybe_group([]), do: [] + + defp maybe_group(_groups), + do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}] + + defp maybe_fee_type([]), do: [] + + defp maybe_fee_type(_fee_types), + do: [ + %{ + key: :fee_type, + group: :membership, + label: gettext("Fee type"), + control: :fee_type_membership + } + ] + + defp custom_fields(custom_fields) do + custom_fields + |> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types)) + |> Enum.map(fn cf -> + %{ + key: to_string(cf.id), + group: :custom_fields, + label: cf.name, + control: control_for_custom_field(cf.value_type) + } + end) + end + + defp control_for_custom_field(:boolean), do: :boolean + defp control_for_custom_field(:date), do: :date_range +end 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 ac3051f0..fa5559ca 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -46,17 +46,18 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do opts[:boolean_custom_fields] ) |> apply_date_filters(opts[:date_filters]) + |> apply_as_of_date_filter(opts[:as_of_date]) |> 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_suspended_filter(opts[:suspended], payment_period(opts)) + |> apply_sort( + opts[:sort_field], + opts[:sort_order], + opts[:custom_fields] || [], + 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 # --------------------------------------------------------------------------- @@ -67,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_search(query, _), do: query # --------------------------------------------------------------------------- - # Group filters (AND across selected groups) + # Group filters (:in OR across selected groups; :not_in AND-of-not-exists) + # + # Multiple "is" selections bundle into a single membership `exists` over the + # union of group ids, so a member matching *any* selected group is kept (OR, + # §1.29/§2.7). Each "is not" selection stays an independent not-exists, so a + # member is excluded when it belongs to *any* of them. # --------------------------------------------------------------------------- defp apply_group_filters(query, group_filters, _groups) @@ -77,31 +83,32 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_group_filters(query, group_filters, groups) do valid_ids = valid_id_set(groups) - Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> - if MapSet.member?(valid_ids, group_id_str) do - apply_one_group_filter(q, group_id_str, value) - else - q + {in_filters, not_in_filters} = + group_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + query + |> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + |> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end)) + end + + defp apply_group_in_filter(query, id_strs) do + case cast_uuids(id_strs) do + [] -> query + uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids))) + end + end + + defp apply_group_not_in_filters(query, id_strs) do + Enum.reduce(id_strs, query, fn id_str, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid))) + _ -> q end end) end - defp apply_one_group_filter(query, group_id_str, :in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid))) - _ -> query - end - end - - defp apply_one_group_filter(query, group_id_str, :not_in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid))) - _ -> query - end - end - - defp apply_one_group_filter(query, _id, _value), do: query - # --------------------------------------------------------------------------- # Fee-type filters (:in OR; :not_in AND) # --------------------------------------------------------------------------- @@ -184,54 +191,84 @@ 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) + # Payment aging filter (period-scoped unpaid-cycle count) # - # 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. + # `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in + # the active period; `{:has_unpaid, n}` keeps members with at least n such + # cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a + # correlated aggregate over `membership_fee_cycles`), so no member set is + # loaded into memory. Suspended/paid cycles are excluded by the calculation. # --------------------------------------------------------------------------- - defp apply_cycle_status_filter(query, status, _show_current, _today) - when status not in [:paid, :unpaid], - do: query + defp payment_period(opts) do + case opts[:payment_period] do + %{from: _, to: _} = period -> period + _ -> %{from: nil, to: nil} + end + end - 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`. + defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do 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) - ) - ) - ) + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0) ) end - defp apply_cycle_status_filter(query, status, _show_current, today) do - # Last completed cycle: most recent cycle that has ended (cycle_end < today). + defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to}) + when is_integer(n) and n > 0 do 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) - ) - ) - ) + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n) ) end + # Two-sided open-cycle range (§1.22/§3.14): keeps members whose in-period + # unpaid count is within [min, max]. `max` nil means unbounded above, so + # `{:unpaid_range, 1, nil}` is the default "has open" (>= 1). Both bounds push + # to SQL through the same `unpaid_cycle_count` calculation. + defp apply_payment_filter(query, {:unpaid_range, min, max}, %{from: from, to: to}) + when is_integer(min) and min > 0 do + query + |> Ash.Query.filter(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^min) + |> apply_unpaid_upper_bound(max, from, to) + end + + defp apply_payment_filter(query, _filter, _period), do: query + + defp apply_unpaid_upper_bound(query, max, from, to) when is_integer(max) and max > 0 do + Ash.Query.filter( + query, + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) <= ^max) + ) + end + + defp apply_unpaid_upper_bound(query, _max, _from, _to), do: query + + # Suspended-status filter (§1.23): keeps members with at least one suspended + # cycle whose cycle_end falls in the active period. Independent of the unpaid + # count, which excludes suspended (§2.5). A correlated `exists` pushes to SQL; + # the period bounds are composed conditionally so an unbounded (nil) side adds + # no `cycle_end` comparison rather than comparing against a literal nil. + defp apply_suspended_filter(query, true, %{from: from, to: to}) do + Ash.Query.filter( + query, + expr(exists(membership_fee_cycles, ^suspended_period_predicate(from, to))) + ) + end + + defp apply_suspended_filter(query, _suspended, _period), do: query + + defp suspended_period_predicate(nil, nil), do: expr(status == :suspended) + + defp suspended_period_predicate(from, nil), + do: expr(status == :suspended and cycle_end >= ^from) + + defp suspended_period_predicate(nil, to), + do: expr(status == :suspended and cycle_end <= ^to) + + defp suspended_period_predicate(from, to), + do: expr(status == :suspended and cycle_end >= ^from and cycle_end <= ^to) + # --------------------------------------------------------------------------- # Built-in date filters (join/exit) # --------------------------------------------------------------------------- @@ -247,21 +284,41 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_custom_date_filters(query, _filters, _custom_fields), do: query + # --------------------------------------------------------------------------- + # Point-in-time membership (as-of date): a member counts as a member on the + # reference date X iff it had joined by X and had not yet left as of X + # (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an + # exit_date strictly after X (or nil) still counts as a member on X. + # --------------------------------------------------------------------------- + + defp apply_as_of_date_filter(query, %Date{} = x) do + Ash.Query.filter( + query, + expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x)) + ) + end + + defp apply_as_of_date_filter(query, _), do: query + # --------------------------------------------------------------------------- # Sort (always append the unique id tie-breaker for keyset stability) # --------------------------------------------------------------------------- - defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) - defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, nil, _order, _custom_fields, _period), + do: Ash.Query.sort(query, id: :asc) + + defp apply_sort(query, _field, nil, _custom_fields, _period), + do: Ash.Query.sort(query, id: :asc) # Composite "Member" column: sort by last name then first name. The email that # shares the cell is display-only and does not affect the sort key (§1.21). - defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do + defp apply_sort(query, field, order, _custom_fields, _period) when field in [:name, "name"] do Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}]) end # Composite "Address" column: sort by city, then postal code, then street. - defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do + defp apply_sort(query, field, order, _custom_fields, _period) + when field in [:address, "address"] do Ash.Query.sort(query, [ {:city, order}, {:postal_code, order}, @@ -270,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do ]) end - defp apply_sort(query, field, order, custom_fields) do + # Fees (payment) column: sort DB-side by the period-scoped unpaid-cycle + # count (§1.27). The count calculation takes the active period as arguments so + # the sort key matches the displayed value; the id tie-breaker keeps keyset + # pages stable across equal counts. + defp apply_sort(query, field, order, _custom_fields, %{from: from, to: to}) + when field in [:payment, "payment"] do + Ash.Query.sort(query, [ + {Ash.Sort.expr_sort(unpaid_cycle_count(period_from: ^from, period_to: ^to), :integer), + order}, + {:id, :asc} + ]) + end + + defp apply_sort(query, field, order, custom_fields, _period) do case sort_key(field) do nil -> apply_custom_field_sort(query, field, order, custom_fields) key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex new file mode 100644 index 00000000..4f48c326 --- /dev/null +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -0,0 +1,395 @@ +defmodule MvWeb.MemberLive.Index.PaymentAging do + @moduledoc """ + Period-scoped payment aging for the member overview. + + The overview presents payment as an accounts-receivable aging count: per + member, the number of that member's cycles with `status == :unpaid` whose + denormalized `cycle_end` falls inside an active period. The period is a view + dimension that scopes the column and the payment filter together; its default + is **all outstanding** (both bounds nil → all time). Suspended cycles are + excluded from the count but surfaced separately in the badge tooltip. + + This module owns: + + * the period value shape and its default, + * URL param encode/decode for the period, + * the badge descriptor derived from a count, + * the open-cycles list (unpaid + suspended, member-relative) that backs the + badge tooltip. + + The count itself is computed DB-side by the `unpaid_cycle_count` calculation + on `Mv.Membership.Member`; `open_cycles/2` works over already-loaded cycles. + """ + + 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} + + @type filter :: + nil + | :fully_paid + | {:has_unpaid, 1..3} + | {:unpaid_range, pos_integer(), pos_integer() | nil} + + @payment_period_from_param Constants.payment_period_from_param() + @payment_period_to_param Constants.payment_period_to_param() + @payment_filter_param Constants.payment_filter_param() + @payment_count_min_param Constants.payment_count_min_param() + @payment_count_max_param Constants.payment_count_max_param() + @suspended_param Constants.suspended_param() + + @doc """ + The default period: all outstanding cycles, all time (both bounds nil). + """ + @spec default_period() :: period() + def default_period, do: %{from: nil, to: nil} + + @doc """ + Decodes URL params into a period. Absent or malformed ISO-8601 bounds fall + back to nil, so no params yields the all-outstanding default. + """ + @spec parse_period(map()) :: period() + def parse_period(params) when is_map(params) do + %{ + from: parse_date(Map.get(params, @payment_period_from_param)), + to: parse_date(Map.get(params, @payment_period_to_param)) + } + end + + @doc """ + Encodes a period into URL params. A nil bound is omitted; the default period + encodes to the empty map (a fresh URL is the canonical default). + """ + @spec to_params(period()) :: %{optional(String.t()) => String.t()} + def to_params(%{from: from, to: to}) do + %{} + |> maybe_put_date(@payment_period_from_param, from) + |> maybe_put_date(@payment_period_to_param, to) + end + + def to_params(_), do: %{} + + @doc """ + Decodes the payment-count filter param. + + * `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period) + * `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}` + (at least N unpaid cycles in the period) + * anything else → `nil` (no payment-count 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} + def parse_filter("unpaid_3"), do: {:has_unpaid, 3} + def parse_filter(_), do: nil + + @doc """ + Encodes a payment-count filter into URL params. `nil` yields the empty map. + + The two-sided `{:unpaid_range, min, max}` form encodes as + `pay_filter=has_unpaid` plus `pay_min` (and `pay_max` when bounded above), + so a fresh "has open" default (`min=1`, `max=nil`) round-trips canonically. + """ + @spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()} + def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"} + + def filter_to_params({:has_unpaid, n}) when n in 1..3, + do: %{@payment_filter_param => "unpaid_#{n}"} + + def filter_to_params({:unpaid_range, min, max}) when is_integer(min) and min > 0 do + %{@payment_filter_param => "has_unpaid", @payment_count_min_param => Integer.to_string(min)} + |> maybe_put_max(max) + end + + def filter_to_params(_), do: %{} + + defp maybe_put_max(params, max) when is_integer(max) and max > 0, + do: Map.put(params, @payment_count_max_param, Integer.to_string(max)) + + defp maybe_put_max(params, _max), do: params + + @doc """ + Decodes the payment-count filter from a full params map. + + A `pay_filter=has_unpaid` value reads the `pay_min`/`pay_max` bounds into a + `{:unpaid_range, min, max}`; an absent/invalid `pay_min` defaults to 1 and an + absent/invalid `pay_max` to nil (unbounded). Other `pay_filter` values fall + back to the single-token decode (`fully_paid` / `unpaid_N`). + """ + @spec parse_filter_params(map()) :: filter() + def parse_filter_params(params) when is_map(params) do + case Map.get(params, @payment_filter_param) do + "has_unpaid" -> + {:unpaid_range, parse_count(Map.get(params, @payment_count_min_param), 1), + parse_count(Map.get(params, @payment_count_max_param), nil)} + + other -> + parse_filter(other) + end + end + + defp parse_count(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {n, ""} when n > 0 -> n + _ -> default + end + end + + defp parse_count(_value, default), do: default + + @doc """ + Decodes the suspended-status filter flag (§1.23) from a params map. The flag + is present (`suspended=1`) only when active; any other value reads as false. + """ + @spec parse_suspended(map()) :: boolean() + def parse_suspended(params) when is_map(params), do: Map.get(params, @suspended_param) == "1" + + @doc """ + Encodes the suspended-status filter flag into URL params. Only `true` emits a + param (`suspended=1`); false/nil yield the empty map so a fresh URL is canonical. + """ + @spec suspended_to_params(boolean() | nil) :: %{optional(String.t()) => String.t()} + def suspended_to_params(true), do: %{@suspended_param => "1"} + def suspended_to_params(_), do: %{} + + @doc """ + Badge descriptor for an unpaid-cycle count. A count of 0 reads "All paid" + (success/green); a positive count reads "N open" (error/red). The color class + is derived from the shared `MembershipFeeHelpers.status_variant/1` mapping, so + the overview badge reads consistently with the member show page. + """ + @spec badge(non_neg_integer()) :: %{ + color_class: String.t(), + icon: String.t(), + label: String.t(), + count: non_neg_integer() + } + def badge(0) do + %{ + color_class: status_badge_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 + %{ + color_class: status_badge_class(:unpaid), + icon: MembershipFeeHelpers.status_icon(:unpaid), + label: gettext("%{count} open", count: count), + count: count + } + end + + @doc """ + DaisyUI badge color class for a cycle status, derived from the single shared + `MembershipFeeHelpers.status_variant/1` mapping so the overview badges, the + tooltip badges and the member show page all stay in lock-step. daisyUI emits + all `badge-*` component colors, so the interpolated class is always bundled. + """ + @spec status_badge_class(:paid | :unpaid | :suspended) :: String.t() + def status_badge_class(status), + do: "badge-#{MembershipFeeHelpers.status_variant(status)}" + + @doc """ + Compact short code for a payment period (§1.28), or nil when the period is not + compactly codeable (the header then falls back to a plain "filtered" badge). + + * `{nil, nil}` (all-outstanding default) → nil (no badge) + * a full calendar year (Jan 1 – Dec 31) → `"2026"` + * a full calendar quarter → `"Q1 2026"` + * anything else (arbitrary or open-ended range) → nil + """ + @spec period_short_code(period()) :: String.t() | nil + def period_short_code(%{from: %Date{} = from, to: %Date{} = to}) do + cond do + full_year?(from, to) -> Integer.to_string(from.year) + full_quarter?(from, to) -> "Q#{quarter(from.month)} #{from.year}" + true -> nil + end + end + + def period_short_code(_), do: nil + + @doc """ + Human-readable tooltip naming the active payment period with locally formatted + (`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( + "The values refer to the filtered contribution period. Fees across all outstanding cycles" + ) + + def period_tooltip(%{from: from, to: to}) do + 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( + "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: "…" + + defp full_year?(from, to), + do: + from.month == 1 and from.day == 1 and to.year == from.year and to.month == 12 and + to.day == 31 + + defp full_quarter?(from, to) do + from.day == 1 and from.month in [1, 4, 7, 10] and to.year == from.year and + to == Date.end_of_month(Date.new!(from.year, from.month + 2, 1)) + end + + @doc """ + 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()) :: [map()] + def open_cycles(member, %{from: from, to: to}) do + member + |> in_period_cycles(from, to) + |> Enum.filter(&(&1.status in [:unpaid, :suspended])) + |> Enum.sort_by(& &1.cycle_start, Date) + end + + @doc """ + Partitions a member's open cycles for the period into the unpaid cycles (which + drive the count and the badge) and the suspended cycles, which the tooltip + surfaces in their own labeled section rather than by color alone (§1.14). + Both lists stay in chronological order. + """ + @spec partition_open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]} + def partition_open_cycles(member, period) do + {unpaid, suspended} = + member + |> open_cycles(period) + |> Enum.split_with(&(&1.status == :unpaid)) + + %{unpaid: unpaid, suspended: suspended} + end + + @tooltip_slots 5 + + @doc """ + Partitions the tooltip's unpaid sub-list into a fixed five-slot budget: at + most five badges for the unpaid cycles. Five or fewer unpaid 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 unpaid sub-list + renders four cycle badges and one grey overflow badge — five items. Suspended + cycles are listed separately (in their own labeled section) and are not capped + by this budget, so the tooltip's overall badge count can exceed five. + """ + @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 """ + 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 → `"March 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)) + _ -> [] + end + end + + defp in_period?(%Date{} = cycle_end, from, to) do + (is_nil(from) or Date.compare(cycle_end, from) != :lt) and + (is_nil(to) or Date.compare(cycle_end, to) != :gt) + end + + defp in_period?(_, _, _), do: false + + 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 + + defp maybe_put_date(params, key, %Date{} = date), + do: Map.put(params, key, Date.to_iso8601(date)) + + defp parse_date(nil), do: nil + + 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/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 db07d05f..5706d57b 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,11 +89,6 @@ msgstr "Aktionen" msgid "Active members" msgstr "Aktive Mitglieder" -#: lib/mv_web/live/components/member_filter_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" @@ -149,7 +127,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 +188,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 +462,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 +472,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 +513,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 +774,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 +784,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 +863,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,22 +1239,12 @@ msgstr "Beispiele" msgid "Exit Date" msgstr "Austrittsdatum" -#: lib/mv_web/live/components/member_filter_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" @@ -1470,6 +1403,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1485,11 +1419,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." @@ -1506,11 +1435,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 @@ -1530,7 +1454,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" @@ -1657,7 +1581,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 @@ -1772,11 +1695,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" @@ -1812,7 +1730,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" @@ -1905,21 +1822,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" @@ -2009,11 +1916,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:" @@ -2108,6 +2010,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" @@ -2154,11 +2057,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." @@ -2244,7 +2142,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" @@ -2408,7 +2305,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 @@ -2430,11 +2327,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" @@ -2694,7 +2586,6 @@ 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/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -2730,16 +2621,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" @@ -2838,11 +2719,6 @@ 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 -#, 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" @@ -3262,11 +3138,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." @@ -3388,6 +3259,7 @@ msgid "Summary" msgstr "Zusammenfassung" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index.html.heex #: 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 @@ -3596,7 +3468,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" @@ -3696,7 +3568,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 @@ -3902,7 +3773,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 @@ -4075,6 +3946,7 @@ msgid "email %{email} has already been taken" msgstr "E-Mail %{email} wurde bereits verwendet" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "gefiltert" @@ -4125,11 +3997,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" @@ -4213,3 +4080,380 @@ msgstr "Klicke, um nach Nachname zu sortieren" #, elixir-autogen, elixir-format msgid "No address" msgstr "Keine Adresse" + +#: 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 +msgid "Group" +msgstr "Gruppe" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Payment" +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 "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 "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 "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 "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/components/add_filter_builder_component.ex +#: 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/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 to sort by open payment cycles" +msgstr "Klicke, um nach offenen Beitragszyklen zu sortieren" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 12 months" +msgstr "Letzte 12 Monate" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 3 months" +msgstr "Letzte 3 Monate" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 7 days" +msgstr "Letzte 7 Tage" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This quarter" +msgstr "Dieses Quartal" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +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 "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 c3b21727..309f5ae4 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,11 +90,6 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_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" @@ -150,7 +128,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 +189,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 +463,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 +473,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 +514,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 +775,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 +785,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 +864,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,22 +1240,12 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_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" @@ -1471,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1486,11 +1420,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." @@ -1507,11 +1436,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 @@ -1531,7 +1455,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 "" @@ -1658,7 +1582,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 @@ -1773,11 +1696,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" @@ -1813,7 +1731,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" @@ -1906,21 +1823,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" @@ -2010,11 +1917,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:" @@ -2109,6 +2011,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" @@ -2155,11 +2058,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." @@ -2245,7 +2143,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" @@ -2409,7 +2306,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 @@ -2431,11 +2328,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" @@ -2695,7 +2587,6 @@ 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/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -2731,16 +2622,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" @@ -2839,11 +2720,6 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_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" @@ -3263,11 +3139,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." @@ -3389,6 +3260,7 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index.html.heex #: 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 @@ -3597,7 +3469,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 "" @@ -3697,7 +3569,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 @@ -3902,7 +3773,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 @@ -4075,6 +3946,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 "" @@ -4125,11 +3997,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" @@ -4213,3 +4080,305 @@ msgstr "" #, elixir-autogen, elixir-format msgid "No address" 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 +msgid "Group" +msgstr "" + +#: 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 "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 "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 "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 "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/components/add_filter_builder_component.ex +#: 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/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 "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +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 +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 +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 "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 fe871774..a8decc87 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,11 +90,6 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_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" @@ -150,7 +128,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 +189,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 +463,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 +473,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 +514,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 +775,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 +785,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 +864,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,22 +1240,12 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_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" @@ -1471,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" @@ -1486,11 +1420,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." @@ -1507,11 +1436,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 @@ -1531,7 +1455,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 "" @@ -1658,7 +1582,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 @@ -1773,11 +1696,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" @@ -1813,7 +1731,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" @@ -1906,21 +1823,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" @@ -2010,11 +1917,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:" @@ -2109,6 +2011,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" @@ -2155,11 +2058,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." @@ -2245,7 +2143,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" @@ -2409,7 +2306,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 @@ -2431,11 +2328,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" @@ -2695,7 +2587,6 @@ 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/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -2731,16 +2622,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" @@ -2839,11 +2720,6 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_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" @@ -3263,11 +3139,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." @@ -3389,6 +3260,7 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index.html.heex #: 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 @@ -3597,7 +3469,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 "" @@ -3697,7 +3569,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 @@ -3902,7 +3773,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 @@ -4075,6 +3946,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 "" @@ -4125,11 +3997,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" @@ -4213,3 +4080,415 @@ msgstr "" #, elixir-autogen, elixir-format msgid "No address" 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 +msgid "Group" +msgstr "" + +#: 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 "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 "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 "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 "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/components/add_filter_builder_component.ex +#: 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/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, 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" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ 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}" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, 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/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs new file mode 100644 index 00000000..b3fb8a55 --- /dev/null +++ b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs @@ -0,0 +1,22 @@ +defmodule Mv.Repo.Migrations.AddMembershipFeeCyclesStatusEndIndex do + @moduledoc """ + Adds a composite index on membership_fee_cycles(member_id, status, cycle_end) + to serve the period-scoped unpaid-cycle count on the member overview. + + This file was autogenerated with `mix ash_postgres.generate_migrations`. + """ + + use Ecto.Migration + + def up do + create index(:membership_fee_cycles, [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + ) + end + + def down do + drop_if_exists index(:membership_fee_cycles, [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + ) + end +end diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json new file mode 100644 index 00000000..5d974b60 --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json @@ -0,0 +1,206 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_start", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_end", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": 2, + "size": null, + "source": "amount", + "type": "decimal" + }, + { + "allow_nil?": false, + "default": "\"unpaid\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "member_id", + "status", + "cycle_end" + ], + "fields": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "status" + }, + { + "type": "atom", + "value": "cycle_end" + } + ], + "include": null, + "message": null, + "name": "membership_fee_cycles_member_status_end_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6CDBA37AC6000D63F3014D3EED08B613B32E8D908D598248747EFA093BC5246A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "membership_fee_cycles_unique_cycle_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "cycle_start" + } + ], + "name": "unique_cycle_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "membership_fee_cycles" +} \ No newline at end of file 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 - # `
` `