Rework Filter using a filter builder #556
65 changed files with 6588 additions and 3123 deletions
|
|
@ -10,12 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### 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 – 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 – 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
|
### 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 – 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 – 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 – 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 – 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
|
## [1.3.0] - 2026-06-16
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -941,6 +941,21 @@
|
||||||
pointer-events: none;
|
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
|
* Vertically center the row checkbox in the sticky first column using the
|
||||||
* table-native vertical-align: middle. This keeps display: table-cell intact so
|
* table-native vertical-align: middle. This keeps display: table-cell intact so
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {Socket} from "phoenix"
|
||||||
import {LiveSocket} from "phoenix_live_view"
|
import {LiveSocket} from "phoenix_live_view"
|
||||||
import topbar from "../vendor/topbar"
|
import topbar from "../vendor/topbar"
|
||||||
import Sortable from "../vendor/sortable"
|
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")
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
|
|
||||||
|
|
@ -36,6 +38,11 @@ function getBrowserTimezone() {
|
||||||
// Hooks for LiveView components
|
// Hooks for LiveView components
|
||||||
let Hooks = {}
|
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
|
// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS
|
||||||
// property, not an HTML attribute, so it cannot be set from the server render.
|
// 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
|
// Mirror it from the data-indeterminate attribute on mount and every update
|
||||||
|
|
|
||||||
33
assets/js/hooks/filter_combobox_search.js
Normal file
33
assets/js/hooks/filter_combobox_search.js
Normal file
|
|
@ -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
|
||||||
76
assets/js/hooks/hover_popover.js
Normal file
76
assets/js/hooks/hover_popover.js
Normal file
|
|
@ -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:
|
||||||
|
// <a phx-hook="HoverPopover" data-popover-target="tip-id" style="anchor-name:--x">…</a>
|
||||||
|
// <div id="tip-id" popover style="position-anchor:--x">…</div>
|
||||||
|
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
|
||||||
|
|
@ -717,6 +717,25 @@ defmodule Mv.Membership.Member do
|
||||||
constraints one_of: [:unpaid, :paid, :suspended]
|
constraints one_of: [:unpaid, :paid, :suspended]
|
||||||
end
|
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
|
calculate :overdue_count, :integer do
|
||||||
description "Count of unpaid cycles that have already ended (cycle_end < today)"
|
description "Count of unpaid cycles that have already ended (cycle_end < today)"
|
||||||
# Automatically load cycles with all attributes and membership_fee_type
|
# Automatically load cycles with all attributes and membership_fee_type
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,16 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
postgres do
|
postgres do
|
||||||
table "membership_fee_cycles"
|
table "membership_fee_cycles"
|
||||||
repo Mv.Repo
|
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
|
end
|
||||||
|
|
||||||
resource do
|
resource do
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,20 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@custom_date_filter_prefix "cdf_"
|
@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_boolean_filters 50
|
||||||
|
|
||||||
@max_mailto_bulk_recipients 50
|
@max_mailto_bulk_recipients 50
|
||||||
|
|
@ -162,6 +176,81 @@ defmodule Mv.Constants do
|
||||||
"""
|
"""
|
||||||
def custom_date_filter_prefix, do: @custom_date_filter_prefix
|
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 """
|
@doc """
|
||||||
Returns the maximum number of boolean custom field filters allowed per request.
|
Returns the maximum number of boolean custom field filters allowed per request.
|
||||||
|
|
||||||
|
|
|
||||||
1355
lib/mv_web/live/components/add_filter_builder_component.ex
Normal file
1355
lib/mv_web/live/components/add_filter_builder_component.ex
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -24,14 +24,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
- `select_row_and_navigate` - open a member's show page
|
- `select_row_and_navigate` - open a member's show page
|
||||||
- `load_more` - fetch and append the next keyset page (infinite scroll)
|
- `load_more` - fetch and append the next keyset page (infinite scroll)
|
||||||
- `sort` - change the active sort column/direction
|
- `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
|
- `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)
|
## Messages (`handle_info`, from the filter/search/view-settings components)
|
||||||
- `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`,
|
- `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`,
|
||||||
`fields_reset`, `reset_all_filters`, and the per-filter change messages
|
`fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter
|
||||||
(`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`,
|
change messages (`group_filter_changed`, `fee_type_filter_changed`,
|
||||||
`date_filters_changed`, `payment_filter_changed`)
|
`boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`,
|
||||||
|
`payment_period_changed`)
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
- Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only
|
- 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
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
alias MvWeb.MemberLive.Index.AsOfDate
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
alias MvWeb.MemberLive.Index.ExportPayload
|
alias MvWeb.MemberLive.Index.ExportPayload
|
||||||
|
|
@ -58,6 +60,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
alias MvWeb.MemberLive.Index.ViewSettings
|
alias MvWeb.MemberLive.Index.ViewSettings
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -174,7 +177,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:query, "")
|
|> assign(:query, "")
|
||||||
|> assign_new(:sort_field, fn -> :first_name end)
|
|> assign_new(:sort_field, fn -> :first_name end)
|
||||||
|> assign_new(:sort_order, fn -> :asc 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(:group_filters, %{})
|
||||||
|> assign(:groups, groups)
|
|> assign(:groups, groups)
|
||||||
|> assign(:fee_type_filters, %{})
|
|> assign(:fee_type_filters, %{})
|
||||||
|
|
@ -203,8 +209,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
:member_fields_visible_computed,
|
:member_fields_visible_computed,
|
||||||
FieldVisibility.get_visible_member_fields_computed(initial_selection)
|
FieldVisibility.get_visible_member_fields_computed(initial_selection)
|
||||||
)
|
)
|
||||||
|> assign(:show_current_cycle, false)
|
|
||||||
|> assign(:membership_fee_status_filter, nil)
|
|
||||||
|> assign(:page, nil)
|
|> assign(:page, nil)
|
||||||
|> assign(:after_cursor, nil)
|
|> assign(:after_cursor, nil)
|
||||||
|> assign(:more?, false)
|
|> assign(:more?, false)
|
||||||
|
|
@ -309,29 +313,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()}
|
|> update_selection_assigns()}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("copy_emails", _params, socket) do
|
def handle_event("copy_emails", _params, socket) do
|
||||||
# Recipients follow the current scope, re-queried from the DB so a no-selection
|
# 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
|
def handle_info({:payment_filter_changed, filter}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:cycle_status_filter, filter)
|
|> assign(:payment_filter, filter)
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> scroll_list_to_top()
|
|> scroll_list_to_top()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
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(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -573,11 +632,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
||||||
socket =
|
socket =
|
||||||
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(:group_filters, Map.get(opts, :group_filters, %{}))
|
||||||
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
||||||
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||||||
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
|> 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()
|
|> load_members()
|
||||||
|> scroll_list_to_top()
|
|> scroll_list_to_top()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
@ -663,12 +725,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket
|
socket
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|> maybe_update_sort(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_group_filters(params)
|
||||||
|> maybe_update_fee_type_filters(params)
|
|> maybe_update_fee_type_filters(params)
|
||||||
|> maybe_update_boolean_filters(params)
|
|> maybe_update_boolean_filters(params)
|
||||||
|> maybe_update_date_filters(params)
|
|> maybe_update_date_filters(params)
|
||||||
|> maybe_update_show_current_cycle(params)
|
|
||||||
|> assign(:fields_in_url?, fields_in_url?)
|
|> assign(:fields_in_url?, fields_in_url?)
|
||||||
|> assign(:query, params["query"])
|
|> assign(:query, params["query"])
|
||||||
|> assign_visibility_derivations(final_selection)
|
|> assign_visibility_derivations(final_selection)
|
||||||
|
|
@ -712,10 +776,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.query,
|
socket.assigns.query,
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
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[:group_filters],
|
||||||
socket.assigns[:fee_type_filters],
|
socket.assigns[:fee_type_filters],
|
||||||
socket.assigns.show_current_cycle,
|
|
||||||
socket.assigns.boolean_custom_field_filters,
|
socket.assigns.boolean_custom_field_filters,
|
||||||
socket.assigns.user_field_selection,
|
socket.assigns.user_field_selection,
|
||||||
socket.assigns[:visible_custom_field_ids] || [],
|
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
|
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 = 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_group_filters(base_params, opts.group_filters || %{})
|
||||||
base_params = add_fee_type_filters(base_params, opts.fee_type_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 || %{})
|
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||||
add_date_filters(base_params, opts.date_filters)
|
add_date_filters(base_params, opts.date_filters)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Suspended-status flag and as-of date serialize additively into the flat
|
||||||
|
# URL contract (suspended=1 / as_of_date=<iso>), 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
|
defp add_date_filters(params, date_filters) do
|
||||||
Map.merge(params, DateFilter.to_params(date_filters))
|
Map.merge(params, DateFilter.to_params(date_filters))
|
||||||
end
|
end
|
||||||
|
|
@ -883,9 +965,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
query: socket.assigns.query,
|
query: socket.assigns.query,
|
||||||
sort_field: socket.assigns.sort_field,
|
sort_field: socket.assigns.sort_field,
|
||||||
sort_order: socket.assigns.sort_order,
|
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] || %{},
|
group_filters: socket.assigns[:group_filters] || %{},
|
||||||
show_current_cycle: socket.assigns.show_current_cycle,
|
|
||||||
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
||||||
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
||||||
date_filters: socket.assigns.date_filters
|
date_filters: socket.assigns.date_filters
|
||||||
|
|
@ -1187,17 +1271,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end)
|
end)
|
||||||
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
|
defp add_boolean_filters(params, boolean_filters) do
|
||||||
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
|
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
|
||||||
end
|
end
|
||||||
|
|
@ -1319,10 +1392,19 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.select(@overview_fields)
|
|> Ash.Query.select(@overview_fields)
|
||||||
|> load_custom_field_values(compute_ids_to_load(socket))
|
|> load_custom_field_values(compute_ids_to_load(socket))
|
||||||
|> MembershipFeeStatus.load_cycles_for_members()
|
|> MembershipFeeStatus.load_cycles_for_members()
|
||||||
|
|> load_unpaid_cycle_count(socket)
|
||||||
|> Ash.Query.load(groups: [:id, :name, :slug])
|
|> Ash.Query.load(groups: [:id, :name, :slug])
|
||||||
|> maybe_load_fee_type(socket)
|
|> maybe_load_fee_type(socket)
|
||||||
end
|
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
|
defp maybe_load_fee_type(query, socket) do
|
||||||
if :membership_fee_type in socket.assigns.member_fields_visible or
|
if :membership_fee_type in socket.assigns.member_fields_visible or
|
||||||
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
|
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,
|
boolean_custom_fields: socket.assigns.boolean_custom_fields,
|
||||||
date_filters: socket.assigns.date_filters,
|
date_filters: socket.assigns.date_filters,
|
||||||
date_custom_fields: socket.assigns[:date_custom_fields],
|
date_custom_fields: socket.assigns[:date_custom_fields],
|
||||||
cycle_status_filter: socket.assigns.cycle_status_filter,
|
payment_filter: socket.assigns.payment_filter,
|
||||||
show_current_cycle: socket.assigns.show_current_cycle,
|
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_field: socket.assigns.sort_field,
|
||||||
sort_order: socket.assigns.sort_order,
|
sort_order: socket.assigns.sort_order,
|
||||||
custom_fields: socket.assigns.all_custom_fields
|
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
|
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||||
|
|
||||||
field in valid_fields or custom_field_sort?(field) or
|
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
|
end
|
||||||
|
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
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 == "membership_fee_type" -> :membership_fee_type
|
||||||
field == "name" -> :name
|
field == "name" -> :name
|
||||||
field == "address" -> :address
|
field == "address" -> :address
|
||||||
|
field == "payment" -> :payment
|
||||||
true -> safe_member_field_atom_only(field)
|
true -> safe_member_field_atom_only(field)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1488,9 +1573,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp determine_field(default, nil), do: default
|
defp determine_field(default, nil), do: default
|
||||||
|
|
||||||
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
||||||
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
|
# sort keys in OverviewQuery): groups aggregate, the composite Name/Address
|
||||||
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
|
# columns, and the period-scoped payment (unpaid-cycle-count) column. Other
|
||||||
@sortable_computed_fields [:groups, :name, :address]
|
# 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
|
defp determine_field(default, sf) when is_binary(sf) do
|
||||||
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
|
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_search(socket, _params), do: socket
|
||||||
|
|
||||||
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
|
# Period-scoped payment model (§3.3): a payment-count filter plus the active
|
||||||
filter = determine_cycle_status_filter(filter_str)
|
# period, both driven purely from the URL so `handle_params` stays the source
|
||||||
assign(socket, :cycle_status_filter, filter)
|
# of truth. Absent params fall back to no filter / the all-outstanding period.
|
||||||
end
|
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),
|
defp maybe_update_payment_period(socket, params),
|
||||||
do: assign(socket, :cycle_status_filter, nil)
|
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
|
defp maybe_update_group_filters(socket, params) when is_map(params) do
|
||||||
prefix = @group_filter_prefix
|
prefix = @group_filter_prefix
|
||||||
|
|
@ -1652,10 +1748,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp normalize_uuid_string(_), do: nil
|
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
|
defp maybe_update_boolean_filters(socket, params) do
|
||||||
boolean_custom_fields =
|
boolean_custom_fields =
|
||||||
socket.assigns.all_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("false"), do: false
|
||||||
defp determine_boolean_filter(_), do: nil
|
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.
|
# 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
|
# When no date filter params are present, this falls through to the
|
||||||
# active_only default — exactly the spec behavior for fresh load (§1.1).
|
# 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)
|
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
|
defp update_selection_assigns(socket) do
|
||||||
selected_members = socket.assigns.selected_members
|
selected_members = socket.assigns.selected_members
|
||||||
# The selection may span members beyond the loaded page (after select-all),
|
# The selection may span members beyond the loaded page (after select-all),
|
||||||
|
|
@ -1939,7 +2042,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp selection_filters_active?(assigns) do
|
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[:group_filters] || %{}) > 0 or
|
||||||
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
||||||
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<%!-- Two-row overview toolbar (Option C): row 1 is search + column/view
|
||||||
|
controls; row 2 is the dedicated filter row (add-filter + applied chips). --%>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-wrap gap-4 items-center">
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SearchBarComponent}
|
module={MvWeb.Components.SearchBarComponent}
|
||||||
|
|
@ -28,52 +31,6 @@
|
||||||
query={@query}
|
query={@query}
|
||||||
placeholder={gettext("Search...")}
|
placeholder={gettext("Search...")}
|
||||||
/>
|
/>
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.MemberFilterComponent}
|
|
||||||
id="member-filter"
|
|
||||||
cycle_status_filter={@cycle_status_filter}
|
|
||||||
groups={@groups}
|
|
||||||
group_filters={@group_filters}
|
|
||||||
fee_types={@fee_types}
|
|
||||||
fee_type_filters={@fee_type_filters}
|
|
||||||
boolean_custom_fields={@boolean_custom_fields}
|
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
|
||||||
date_custom_fields={@date_custom_fields}
|
|
||||||
date_filters={@date_filters}
|
|
||||||
member_count={@total_count}
|
|
||||||
/>
|
|
||||||
<.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" />
|
|
||||||
<span class="hidden sm:inline">
|
|
||||||
{if(@show_current_cycle,
|
|
||||||
do: gettext("Current payment cycle"),
|
|
||||||
else: gettext("Last payment cycle")
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</.button>
|
|
||||||
</.tooltip>
|
|
||||||
<div class="ml-auto flex items-center gap-4">
|
<div class="ml-auto flex items-center gap-4">
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||||
|
|
@ -93,6 +50,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.AddFilterBuilderComponent}
|
||||||
|
id="member-filter"
|
||||||
|
groups={@groups}
|
||||||
|
group_filters={@group_filters}
|
||||||
|
fee_types={@fee_types}
|
||||||
|
fee_type_filters={@fee_type_filters}
|
||||||
|
boolean_custom_fields={@boolean_custom_fields}
|
||||||
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
|
date_custom_fields={@date_custom_fields}
|
||||||
|
date_filters={@date_filters}
|
||||||
|
payment_filter={@payment_filter}
|
||||||
|
payment_period={@payment_period}
|
||||||
|
suspended={@suspended}
|
||||||
|
as_of_date={@as_of_date}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Polite live region: present on first render (before it is filled) so
|
<%!-- Polite live region: present on first render (before it is filled) so
|
||||||
screen readers announce the exact total matching count on every filter change
|
screen readers announce the exact total matching count on every filter change
|
||||||
(WCAG 4.1.3). --%>
|
(WCAG 4.1.3). --%>
|
||||||
|
|
@ -131,7 +106,6 @@
|
||||||
JS.push("select_row_and_navigate", value: %{id: member.id})
|
JS.push("select_row_and_navigate", value: %{id: member.id})
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
row_tooltip={gettext("Click for member details")}
|
|
||||||
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
|
|
@ -484,17 +458,116 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
:if={:membership_fee_status in @member_fields_visible}
|
||||||
label={gettext("Membership Fee Status")}
|
sort_field={:payment}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<.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")}
|
||||||
|
</.badge>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<% 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"
|
||||||
>
|
>
|
||||||
<%= 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" />
|
<.icon name={badge.icon} class="size-4" />
|
||||||
{badge.label}
|
{badge.label}
|
||||||
|
</.link>
|
||||||
|
<%= if count > 0 do %>
|
||||||
|
<% %{unpaid: unpaid, suspended: suspended} =
|
||||||
|
PaymentAging.partition_open_cycles(member, @payment_period) %>
|
||||||
|
<% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %>
|
||||||
|
<div
|
||||||
|
id={tip_id}
|
||||||
|
popover="manual"
|
||||||
|
role="tooltip"
|
||||||
|
style={"position-anchor: #{anchor}"}
|
||||||
|
class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
|
||||||
|
data-testid="payment-tooltip"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="m-0 flex list-none flex-col items-stretch gap-1 p-0"
|
||||||
|
data-testid="payment-tooltip-unpaid"
|
||||||
|
>
|
||||||
|
<li :for={cycle <- shown} class="flex">
|
||||||
|
<.badge
|
||||||
|
variant={MembershipFeeHelpers.status_variant(cycle.status)}
|
||||||
|
size="sm"
|
||||||
|
class="w-full justify-start"
|
||||||
|
>
|
||||||
|
<:icon>
|
||||||
|
<.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-3.5" />
|
||||||
|
</:icon>
|
||||||
|
{PaymentAging.short_period(cycle)}
|
||||||
</.badge>
|
</.badge>
|
||||||
<% else %>
|
</li>
|
||||||
<.empty_cell sr_text={gettext("No cycle")} />
|
<li :if={overflow > 0} class="flex">
|
||||||
|
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
|
||||||
|
<.icon name="hero-plus-circle" class="size-3.5" />
|
||||||
|
{gettext("%{count} more", count: overflow)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<%!-- Suspended cycles are shown separately under their own label so
|
||||||
|
the status distinction is not color-only (§1.14). --%>
|
||||||
|
<div :if={suspended != []} class="mt-2" data-testid="payment-tooltip-suspended">
|
||||||
|
<p class="mb-1 text-xs font-medium opacity-70">{gettext("Suspended")}</p>
|
||||||
|
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
|
||||||
|
<li :for={cycle <- suspended} class="flex">
|
||||||
|
<.badge
|
||||||
|
variant={MembershipFeeHelpers.status_variant(cycle.status)}
|
||||||
|
size="sm"
|
||||||
|
class="w-full justify-start"
|
||||||
|
>
|
||||||
|
<:icon>
|
||||||
|
<.icon
|
||||||
|
name={MembershipFeeHelpers.status_icon(cycle.status)}
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
</:icon>
|
||||||
|
{PaymentAging.short_period(cycle)}
|
||||||
|
</.badge>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
|
|
|
||||||
37
lib/mv_web/live/member_live/index/as_of_date.ex
Normal file
37
lib/mv_web/live/member_live/index/as_of_date.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -67,6 +67,52 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
||||||
}
|
}
|
||||||
end
|
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 """
|
@doc """
|
||||||
Decodes URL params into a date filter state map.
|
Decodes URL params into a date filter state map.
|
||||||
|
|
||||||
|
|
|
||||||
70
lib/mv_web/live/member_live/index/date_presets.ex
Normal file
70
lib/mv_web/live/member_live/index/date_presets.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
|
||||||
query: assigns[:query] || nil,
|
query: assigns[:query] || nil,
|
||||||
sort_field: sort_field(assigns[:sort_field]),
|
sort_field: sort_field(assigns[:sort_field]),
|
||||||
sort_order: sort_order(assigns[:sort_order]),
|
sort_order: sort_order(assigns[:sort_order]),
|
||||||
show_current_cycle: assigns[:show_current_cycle] || false,
|
# Decoupled from the removed overview current/last cycle toggle (§3.3): the
|
||||||
cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
|
# 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] || %{}
|
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
|
||||||
def sort_order(:desc), do: "desc"
|
def sort_order(:desc), do: "desc"
|
||||||
def sort_order(o) when is_binary(o), do: o
|
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
|
defp build_member_fields_list(ordered_db, member_fields_visible) do
|
||||||
member_fields_visible = member_fields_visible || []
|
member_fields_visible = member_fields_visible || []
|
||||||
|
|
||||||
|
|
|
||||||
145
lib/mv_web/live/member_live/index/filter_descriptor.ex
Normal file
145
lib/mv_web/live/member_live/index/filter_descriptor.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -46,17 +46,18 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
opts[:boolean_custom_fields]
|
opts[:boolean_custom_fields]
|
||||||
)
|
)
|
||||||
|> apply_date_filters(opts[:date_filters])
|
|> 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_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||||
|> apply_cycle_status_filter(
|
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||||
opts[:cycle_status_filter],
|
|> apply_suspended_filter(opts[:suspended], payment_period(opts))
|
||||||
opts[:show_current_cycle],
|
|> apply_sort(
|
||||||
today(opts)
|
opts[:sort_field],
|
||||||
|
opts[:sort_order],
|
||||||
|
opts[:custom_fields] || [],
|
||||||
|
payment_period(opts)
|
||||||
)
|
)
|
||||||
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp today(opts), do: opts[:today] || Date.utc_today()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Search
|
# Search
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -67,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
defp apply_search(query, _), do: query
|
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)
|
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
|
defp apply_group_filters(query, group_filters, groups) do
|
||||||
valid_ids = valid_id_set(groups)
|
valid_ids = valid_id_set(groups)
|
||||||
|
|
||||||
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
|
{in_filters, not_in_filters} =
|
||||||
if MapSet.member?(valid_ids, group_id_str) do
|
group_filters
|
||||||
apply_one_group_filter(q, group_id_str, value)
|
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||||||
else
|
|> Enum.split_with(fn {_, value} -> value == :in end)
|
||||||
q
|
|
||||||
|
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)
|
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)
|
# 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
|
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
|
# `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in
|
||||||
# matches only when its selected-cycle status equals the requested status;
|
# the active period; `{:has_unpaid, n}` keeps members with at least n such
|
||||||
# members with no matching cycle (nil aggregate) are excluded — exactly as the
|
# cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a
|
||||||
# previous in-memory classifier behaved.
|
# 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)
|
defp payment_period(opts) do
|
||||||
when status not in [:paid, :unpaid],
|
case opts[:payment_period] do
|
||||||
do: query
|
%{from: _, to: _} = period -> period
|
||||||
|
_ -> %{from: nil, to: nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
|
defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do
|
||||||
# Current cycle: contains today; when several would, the one with the latest
|
|
||||||
# cycle_start wins. Keep members whose winning current cycle has `status`.
|
|
||||||
Ash.Query.filter(
|
Ash.Query.filter(
|
||||||
query,
|
query,
|
||||||
expr(
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0)
|
||||||
exists(
|
|
||||||
membership_fee_cycles,
|
|
||||||
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
|
|
||||||
not exists(
|
|
||||||
member.membership_fee_cycles,
|
|
||||||
cycle_start <= ^today and cycle_end >= ^today and
|
|
||||||
cycle_start > parent(cycle_start)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_cycle_status_filter(query, status, _show_current, today) do
|
defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to})
|
||||||
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
|
when is_integer(n) and n > 0 do
|
||||||
Ash.Query.filter(
|
Ash.Query.filter(
|
||||||
query,
|
query,
|
||||||
expr(
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n)
|
||||||
exists(
|
|
||||||
membership_fee_cycles,
|
|
||||||
cycle_end < ^today and status == ^status and
|
|
||||||
not exists(
|
|
||||||
member.membership_fee_cycles,
|
|
||||||
cycle_end < ^today and cycle_start > parent(cycle_start)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
end
|
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)
|
# 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
|
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)
|
# 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, nil, _order, _custom_fields, _period),
|
||||||
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
|
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
|
# 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).
|
# 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}])
|
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
|
||||||
end
|
end
|
||||||
|
|
||||||
# Composite "Address" column: sort by city, then postal code, then street.
|
# 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, [
|
Ash.Query.sort(query, [
|
||||||
{:city, order},
|
{:city, order},
|
||||||
{:postal_code, order},
|
{:postal_code, order},
|
||||||
|
|
@ -270,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
])
|
])
|
||||||
end
|
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
|
case sort_key(field) do
|
||||||
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
||||||
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
||||||
|
|
|
||||||
395
lib/mv_web/live/member_live/index/payment_aging.ex
Normal file
395
lib/mv_web/live/member_live/index/payment_aging.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(%{"id" => id}, _, socket) do
|
def handle_params(%{"id" => id} = params, _, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
# Load custom fields once using assign_new to avoid repeated queries
|
# Load custom fields once using assign_new to avoid repeated queries
|
||||||
|
|
@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> Layouts.assign_page_title(content_title)
|
|> Layouts.assign_page_title(content_title)
|
||||||
|> assign(:member, member)}
|
|> assign(:member, member)
|
||||||
|
|> maybe_assign_tab(params)}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
|
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
|
||||||
{:noreply, assign(socket, :active_tab, :contact)}
|
{:noreply, assign(socket, :active_tab, :contact)}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,6 @@ msgstr " (Datenfeld: %{field})"
|
||||||
msgid "%{count} failed"
|
msgid "%{count} failed"
|
||||||
msgstr "%{count} fehlgeschlagen"
|
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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "%{count} member has a value assigned for this datafield."
|
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"
|
msgid "%{count} synced"
|
||||||
msgstr "%{count} synchronisiert"
|
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
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
||||||
|
|
@ -106,11 +89,6 @@ msgstr "Aktionen"
|
||||||
msgid "Active members"
|
msgid "Active members"
|
||||||
msgstr "Aktive Mitglieder"
|
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
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
|
|
@ -149,7 +127,7 @@ msgid "Administration"
|
||||||
msgstr "Administration"
|
msgstr "Administration"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
|
|
@ -210,11 +188,6 @@ msgstr "App-URL (Link zur Kontaktansicht)"
|
||||||
msgid "Applicant data"
|
msgid "Applicant data"
|
||||||
msgstr "Angaben des Antragstellers"
|
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
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Approve"
|
msgid "Approve"
|
||||||
|
|
@ -489,11 +462,6 @@ msgstr "CSV-Datei auswählen"
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr "Stadt"
|
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
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click for details"
|
msgid "Click for details"
|
||||||
|
|
@ -504,11 +472,6 @@ msgstr "Klicken für Details"
|
||||||
msgid "Click for group details"
|
msgid "Click for group details"
|
||||||
msgstr "Klicke für Gruppen-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
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click for role details"
|
msgid "Click for role details"
|
||||||
|
|
@ -550,11 +513,6 @@ msgstr "Client-ID"
|
||||||
msgid "Client Secret"
|
msgid "Client Secret"
|
||||||
msgstr "Client-Geheimnis"
|
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
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close sidebar"
|
msgid "Close sidebar"
|
||||||
|
|
@ -816,11 +774,6 @@ msgstr "Aktueller Zyklus"
|
||||||
msgid "Current amount"
|
msgid "Current amount"
|
||||||
msgstr "Aktueller Betrag"
|
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
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom"
|
msgid "Custom"
|
||||||
|
|
@ -831,11 +784,6 @@ msgstr "Benutzerdefiniert"
|
||||||
msgid "Custom Fields"
|
msgid "Custom Fields"
|
||||||
msgstr "Benutzerdefinierte Felder"
|
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
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
|
|
@ -915,11 +863,6 @@ msgstr "Datenfelder"
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr "Datum"
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Deactivate"
|
msgid "Deactivate"
|
||||||
|
|
@ -1296,22 +1239,12 @@ msgstr "Beispiele"
|
||||||
msgid "Exit Date"
|
msgid "Exit Date"
|
||||||
msgstr "Austrittsdatum"
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exit date"
|
msgid "Exit date"
|
||||||
msgstr "Austrittsdatum"
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exits"
|
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."
|
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/import_live/components.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type"
|
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."
|
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."
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee types could not be loaded."
|
msgid "Fee types could not be loaded."
|
||||||
|
|
@ -1506,11 +1435,6 @@ msgstr "Feld"
|
||||||
msgid "Fields on the join form"
|
msgid "Fields on the join form"
|
||||||
msgstr "Felder im Beitrittsformular"
|
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/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.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."
|
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."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "From"
|
msgid "From"
|
||||||
msgstr "Von"
|
msgstr "Von"
|
||||||
|
|
@ -1657,7 +1581,6 @@ msgid "Group saved successfully."
|
||||||
msgstr "Gruppe erfolgreich gespeichert."
|
msgstr "Gruppe erfolgreich gespeichert."
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: 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/group_live/index.ex
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: 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"
|
msgid "Inactive members"
|
||||||
msgstr "Inaktive Mitglieder"
|
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
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Include both letters and numbers"
|
msgid "Include both letters and numbers"
|
||||||
|
|
@ -1812,7 +1730,6 @@ msgstr "Falsche E-Mail oder Passwort"
|
||||||
msgid "Individual Datafields"
|
msgid "Individual Datafields"
|
||||||
msgstr "Individuelle Datenfelder"
|
msgstr "Individuelle Datenfelder"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Individual datafields"
|
msgid "Individual datafields"
|
||||||
|
|
@ -1905,21 +1822,11 @@ msgstr "Beitrittsformular"
|
||||||
msgid "Join confirmation"
|
msgid "Join confirmation"
|
||||||
msgstr "Beitrittsbestätigung"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join date"
|
msgid "Join date"
|
||||||
msgstr "Beitrittsdatum"
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join form enabled"
|
msgid "Join form enabled"
|
||||||
|
|
@ -2009,11 +1916,6 @@ msgstr "Nachname"
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
msgstr "Nachname"
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Last sync result:"
|
msgid "Last sync result:"
|
||||||
|
|
@ -2108,6 +2010,7 @@ msgstr "Als ausgesetzt markieren"
|
||||||
msgid "Mark as unpaid"
|
msgid "Mark as unpaid"
|
||||||
msgstr "Als unbezahlt markieren"
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
|
|
@ -2154,11 +2057,6 @@ msgstr "Mitgliedsfeld"
|
||||||
msgid "Member field %{action} successfully"
|
msgid "Member field %{action} successfully"
|
||||||
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
|
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
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member is not in this group."
|
msgid "Member is not in this group."
|
||||||
|
|
@ -2244,7 +2142,6 @@ msgstr "Mitgliedsbeitrag"
|
||||||
msgid "Membership Fee Start Date"
|
msgid "Membership Fee Start Date"
|
||||||
msgstr "Startdatum Mitgliedsbeitrag"
|
msgstr "Startdatum Mitgliedsbeitrag"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership Fee Status"
|
msgid "Membership Fee Status"
|
||||||
|
|
@ -2408,7 +2305,7 @@ msgid "New amount"
|
||||||
msgstr "Neuer Betrag"
|
msgstr "Neuer Betrag"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.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"
|
msgid "No approved or rejected requests yet"
|
||||||
msgstr "Noch keine genehmigten oder abgelehnten Anträge"
|
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
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No cycles"
|
msgid "No cycles"
|
||||||
|
|
@ -2694,7 +2586,6 @@ msgid "Options"
|
||||||
msgstr "Optionen"
|
msgstr "Optionen"
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
|
@ -2730,16 +2621,6 @@ msgstr "Beitragsdaten"
|
||||||
msgid "Payment Interval"
|
msgid "Payment Interval"
|
||||||
msgstr "Zahlungsintervall"
|
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
|
#: lib/mv_web/live/join_request_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pending confirmation"
|
msgid "Pending confirmation"
|
||||||
|
|
@ -2838,11 +2719,6 @@ msgstr "Vierteljährlich"
|
||||||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
msgid "Quarterly Interval - Joining Cycle Excluded"
|
||||||
msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Reactivate member"
|
msgid "Reactivate member"
|
||||||
|
|
@ -3262,11 +3138,6 @@ msgstr "Server nicht erreichbar. Host und Port prüfen."
|
||||||
msgid "Set Password"
|
msgid "Set Password"
|
||||||
msgstr "Passwort setzen"
|
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
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Settings saved successfully."
|
msgid "Settings saved successfully."
|
||||||
|
|
@ -3388,6 +3259,7 @@ msgid "Summary"
|
||||||
msgstr "Zusammenfassung"
|
msgstr "Zusammenfassung"
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: 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/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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"
|
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"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr "Bis"
|
msgstr "Bis"
|
||||||
|
|
@ -3696,7 +3568,6 @@ msgid "Unlinking scheduled"
|
||||||
msgstr "Aufhebung der Verknüpfung geplant"
|
msgstr "Aufhebung der Verknüpfung geplant"
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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"
|
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.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"
|
msgstr "E-Mail %{email} wurde bereits verwendet"
|
||||||
|
|
||||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "filtered"
|
msgid "filtered"
|
||||||
msgstr "gefiltert"
|
msgstr "gefiltert"
|
||||||
|
|
@ -4125,11 +3997,6 @@ msgstr "aktualisiert"
|
||||||
msgid "updated"
|
msgid "updated"
|
||||||
msgstr "aktualisiert"
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
|
|
@ -4213,3 +4080,380 @@ msgstr "Klicke, um nach Nachname zu sortieren"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr "Keine Adresse"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,6 @@ msgstr ""
|
||||||
msgid "%{count} failed"
|
msgid "%{count} failed"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "%{count} member has a value assigned for this datafield."
|
msgid "%{count} member has a value assigned for this datafield."
|
||||||
|
|
@ -40,16 +33,6 @@ msgstr[1] ""
|
||||||
msgid "%{count} synced"
|
msgid "%{count} synced"
|
||||||
msgstr ""
|
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
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
||||||
|
|
@ -107,11 +90,6 @@ msgstr ""
|
||||||
msgid "Active members"
|
msgid "Active members"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
|
|
@ -150,7 +128,7 @@ msgid "Administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
|
|
@ -211,11 +189,6 @@ msgstr ""
|
||||||
msgid "Applicant data"
|
msgid "Applicant data"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Approve"
|
msgid "Approve"
|
||||||
|
|
@ -490,11 +463,6 @@ msgstr ""
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click for details"
|
msgid "Click for details"
|
||||||
|
|
@ -505,11 +473,6 @@ msgstr ""
|
||||||
msgid "Click for group details"
|
msgid "Click for group details"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click for role details"
|
msgid "Click for role details"
|
||||||
|
|
@ -551,11 +514,6 @@ msgstr ""
|
||||||
msgid "Client Secret"
|
msgid "Client Secret"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Close"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close sidebar"
|
msgid "Close sidebar"
|
||||||
|
|
@ -817,11 +775,6 @@ msgstr ""
|
||||||
msgid "Current amount"
|
msgid "Current amount"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom"
|
msgid "Custom"
|
||||||
|
|
@ -832,11 +785,6 @@ msgstr ""
|
||||||
msgid "Custom Fields"
|
msgid "Custom Fields"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
|
|
@ -916,11 +864,6 @@ msgstr ""
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Deactivate"
|
msgid "Deactivate"
|
||||||
|
|
@ -1297,22 +1240,12 @@ msgstr ""
|
||||||
msgid "Exit Date"
|
msgid "Exit Date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exit date"
|
msgid "Exit date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exits"
|
msgid "Exits"
|
||||||
|
|
@ -1471,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: 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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type"
|
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."
|
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 ""
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee types could not be loaded."
|
msgid "Fee types could not be loaded."
|
||||||
|
|
@ -1507,11 +1436,6 @@ msgstr ""
|
||||||
msgid "Fields on the join form"
|
msgid "Fields on the join form"
|
||||||
msgstr ""
|
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/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.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."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "From"
|
msgid "From"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1658,7 +1582,6 @@ msgid "Group saved successfully."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: 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/group_live/index.ex
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
|
@ -1773,11 +1696,6 @@ msgstr ""
|
||||||
msgid "Inactive members"
|
msgid "Inactive members"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Include both letters and numbers"
|
msgid "Include both letters and numbers"
|
||||||
|
|
@ -1813,7 +1731,6 @@ msgstr ""
|
||||||
msgid "Individual Datafields"
|
msgid "Individual Datafields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Individual datafields"
|
msgid "Individual datafields"
|
||||||
|
|
@ -1906,21 +1823,11 @@ msgstr ""
|
||||||
msgid "Join confirmation"
|
msgid "Join confirmation"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join date"
|
msgid "Join date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join form enabled"
|
msgid "Join form enabled"
|
||||||
|
|
@ -2010,11 +1917,6 @@ msgstr ""
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Last sync result:"
|
msgid "Last sync result:"
|
||||||
|
|
@ -2109,6 +2011,7 @@ msgstr ""
|
||||||
msgid "Mark as unpaid"
|
msgid "Mark as unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
|
|
@ -2155,11 +2058,6 @@ msgstr ""
|
||||||
msgid "Member field %{action} successfully"
|
msgid "Member field %{action} successfully"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member is not in this group."
|
msgid "Member is not in this group."
|
||||||
|
|
@ -2245,7 +2143,6 @@ msgstr ""
|
||||||
msgid "Membership Fee Start Date"
|
msgid "Membership Fee Start Date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership Fee Status"
|
msgid "Membership Fee Status"
|
||||||
|
|
@ -2409,7 +2306,7 @@ msgid "New amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
|
@ -2431,11 +2328,6 @@ msgstr ""
|
||||||
msgid "No approved or rejected requests yet"
|
msgid "No approved or rejected requests yet"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No cycles"
|
msgid "No cycles"
|
||||||
|
|
@ -2695,7 +2587,6 @@ msgid "Options"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
|
@ -2731,16 +2622,6 @@ msgstr ""
|
||||||
msgid "Payment Interval"
|
msgid "Payment Interval"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/join_request_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pending confirmation"
|
msgid "Pending confirmation"
|
||||||
|
|
@ -2839,11 +2720,6 @@ msgstr ""
|
||||||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
msgid "Quarterly Interval - Joining Cycle Excluded"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Reactivate member"
|
msgid "Reactivate member"
|
||||||
|
|
@ -3263,11 +3139,6 @@ msgstr ""
|
||||||
msgid "Set Password"
|
msgid "Set Password"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Settings saved successfully."
|
msgid "Settings saved successfully."
|
||||||
|
|
@ -3389,6 +3260,7 @@ msgid "Summary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: 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/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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"
|
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3697,7 +3569,6 @@ msgid "Unlinking scheduled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
|
@ -3902,7 +3773,7 @@ msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
|
@ -4075,6 +3946,7 @@ msgid "email %{email} has already been taken"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "filtered"
|
msgid "filtered"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4125,11 +3997,6 @@ msgstr ""
|
||||||
msgid "updated"
|
msgid "updated"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
|
|
@ -4213,3 +4080,305 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,6 @@ msgstr ""
|
||||||
msgid "%{count} failed"
|
msgid "%{count} failed"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "%{count} member has a value assigned for this datafield."
|
msgid "%{count} member has a value assigned for this datafield."
|
||||||
|
|
@ -40,16 +33,6 @@ msgstr[1] ""
|
||||||
msgid "%{count} synced"
|
msgid "%{count} synced"
|
||||||
msgstr ""
|
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
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
msgid "(ISO-8601 format: YYYY-MM-DD)"
|
||||||
|
|
@ -107,11 +90,6 @@ msgstr ""
|
||||||
msgid "Active members"
|
msgid "Active members"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
|
|
@ -150,7 +128,7 @@ msgid "Administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
|
|
@ -211,11 +189,6 @@ msgstr ""
|
||||||
msgid "Applicant data"
|
msgid "Applicant data"
|
||||||
msgstr "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
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Approve"
|
msgid "Approve"
|
||||||
|
|
@ -490,11 +463,6 @@ msgstr ""
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click for details"
|
msgid "Click for details"
|
||||||
|
|
@ -505,11 +473,6 @@ msgstr "Click for details"
|
||||||
msgid "Click for group details"
|
msgid "Click for group details"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click for role details"
|
msgid "Click for role details"
|
||||||
|
|
@ -551,11 +514,6 @@ msgstr ""
|
||||||
msgid "Client Secret"
|
msgid "Client Secret"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close sidebar"
|
msgid "Close sidebar"
|
||||||
|
|
@ -817,11 +775,6 @@ msgstr ""
|
||||||
msgid "Current amount"
|
msgid "Current amount"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Custom"
|
msgid "Custom"
|
||||||
|
|
@ -832,11 +785,6 @@ msgstr ""
|
||||||
msgid "Custom Fields"
|
msgid "Custom Fields"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
|
|
@ -916,11 +864,6 @@ msgstr ""
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Deactivate"
|
msgid "Deactivate"
|
||||||
|
|
@ -1297,22 +1240,12 @@ msgstr ""
|
||||||
msgid "Exit Date"
|
msgid "Exit Date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Exit date"
|
msgid "Exit date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Exits"
|
msgid "Exits"
|
||||||
|
|
@ -1471,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: 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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Fee type"
|
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."
|
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 ""
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee types could not be loaded."
|
msgid "Fee types could not be loaded."
|
||||||
|
|
@ -1507,11 +1436,6 @@ msgstr ""
|
||||||
msgid "Fields on the join form"
|
msgid "Fields on the join form"
|
||||||
msgstr ""
|
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/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/translations/member_fields.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."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "From"
|
msgid "From"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1658,7 +1582,6 @@ msgid "Group saved successfully."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: 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/group_live/index.ex
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
|
@ -1773,11 +1696,6 @@ msgstr ""
|
||||||
msgid "Inactive members"
|
msgid "Inactive members"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Include both letters and numbers"
|
msgid "Include both letters and numbers"
|
||||||
|
|
@ -1813,7 +1731,6 @@ msgstr ""
|
||||||
msgid "Individual Datafields"
|
msgid "Individual Datafields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Individual datafields"
|
msgid "Individual datafields"
|
||||||
|
|
@ -1906,21 +1823,11 @@ msgstr ""
|
||||||
msgid "Join confirmation"
|
msgid "Join confirmation"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Join date"
|
msgid "Join date"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Join form enabled"
|
msgid "Join form enabled"
|
||||||
|
|
@ -2010,11 +1917,6 @@ msgstr ""
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Last sync result:"
|
msgid "Last sync result:"
|
||||||
|
|
@ -2109,6 +2011,7 @@ msgstr ""
|
||||||
msgid "Mark as unpaid"
|
msgid "Mark as unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
|
|
@ -2155,11 +2058,6 @@ msgstr ""
|
||||||
msgid "Member field %{action} successfully"
|
msgid "Member field %{action} successfully"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member is not in this group."
|
msgid "Member is not in this group."
|
||||||
|
|
@ -2245,7 +2143,6 @@ msgstr ""
|
||||||
msgid "Membership Fee Start Date"
|
msgid "Membership Fee Start Date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/translations/member_fields.ex
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership Fee Status"
|
msgid "Membership Fee Status"
|
||||||
|
|
@ -2409,7 +2306,7 @@ msgid "New amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
|
@ -2431,11 +2328,6 @@ msgstr ""
|
||||||
msgid "No approved or rejected requests yet"
|
msgid "No approved or rejected requests yet"
|
||||||
msgstr "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
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No cycles"
|
msgid "No cycles"
|
||||||
|
|
@ -2695,7 +2587,6 @@ msgid "Options"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
|
@ -2731,16 +2622,6 @@ msgstr ""
|
||||||
msgid "Payment Interval"
|
msgid "Payment Interval"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/join_request_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pending confirmation"
|
msgid "Pending confirmation"
|
||||||
|
|
@ -2839,11 +2720,6 @@ msgstr ""
|
||||||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
msgid "Quarterly Interval - Joining Cycle Excluded"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/deactivate_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Reactivate member"
|
msgid "Reactivate member"
|
||||||
|
|
@ -3263,11 +3139,6 @@ msgstr ""
|
||||||
msgid "Set Password"
|
msgid "Set Password"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Settings saved successfully."
|
msgid "Settings saved successfully."
|
||||||
|
|
@ -3389,6 +3260,7 @@ msgid "Summary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: 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/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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"
|
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3697,7 +3569,6 @@ msgid "Unlinking scheduled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/members_pdf.ex
|
#: lib/mv/membership/members_pdf.ex
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
|
@ -3902,7 +3773,7 @@ msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/join_request_live/show.ex
|
#: lib/mv_web/live/join_request_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
|
@ -4075,6 +3946,7 @@ msgid "email %{email} has already been taken"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "filtered"
|
msgid "filtered"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4125,11 +3997,6 @@ msgstr ""
|
||||||
msgid "updated"
|
msgid "updated"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
|
|
@ -4213,3 +4080,415 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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 `<input>` 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
|
|
||||||
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
|
|
||||||
# preceding the `<input>`. The raw inline form has no such wrapper —
|
|
||||||
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
|
|
||||||
# We assert that fingerprint on each of the date inputs.
|
|
||||||
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()
|
|
||||||
|
|
||||||
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
|
|
||||||
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
|
|
||||||
# window of HTML) by an `<input>` carrying the expected `name`. The
|
|
||||||
# window prevents the regex from spanning unrelated `mb-2` /
|
|
||||||
# `fieldset` occurrences scattered across the dropdown. The wrapper
|
|
||||||
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
|
|
||||||
# (see `lib/mv_web/components/core_components.ex` — every input
|
|
||||||
# branch starts with `<fieldset class="mb-2 fieldset">`).
|
|
||||||
assert Regex.match?(
|
|
||||||
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
|
|
||||||
dropdown_html
|
|
||||||
),
|
|
||||||
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
|
|
||||||
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
|
|
||||||
"<input type=\"date\"> element"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date defaults to :active_only in the rendered radio", %{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 =~
|
|
||||||
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
|
|
||||||
conn: conn
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
for i <- 1..5 do
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: system_actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
section_html = custom_date_section_html(view)
|
|
||||||
|
|
||||||
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
|
|
||||||
refute section_html =~ "max-h-60"
|
|
||||||
refute section_html =~ "overflow-y-auto"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
|
|
||||||
conn: conn
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
for i <- 1..6 do
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date
|
|
||||||
})
|
|
||||||
|> Ash.create!(actor: system_actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
section_html = custom_date_section_html(view)
|
|
||||||
|
|
||||||
# With more than 5 fields the section is wrapped in the scrollable container.
|
|
||||||
assert section_html =~ "max-h-60"
|
|
||||||
assert section_html =~ "overflow-y-auto"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract the HTML of the rendered "Custom date fields" section. Returns
|
|
||||||
# "" if the section is not rendered. Used by the threshold tests to avoid
|
|
||||||
# picking up scrollable classes from sibling sections.
|
|
||||||
defp custom_date_section_html(view) do
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
label = gettext("Custom date fields")
|
|
||||||
|
|
||||||
case String.split(dropdown_html, label, parts: 2) do
|
|
||||||
[_before, after_label] ->
|
|
||||||
# Up to the next group header label, or the footer.
|
|
||||||
after_label
|
|
||||||
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|
|
||||||
|> List.first()
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view_no_field, _} = live(conn, "/members")
|
|
||||||
|
|
||||||
view_no_field
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
dropdown_html =
|
|
||||||
view_no_field
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
refute dropdown_html =~ gettext("Custom date fields")
|
|
||||||
|
|
||||||
# Add a date-typed custom field and re-load: the section appears.
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
{:ok, field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Birthday-#{System.unique_integer([:positive])}",
|
|
||||||
value_type: :date
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
{:ok, view, _} = 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("Custom date fields")
|
|
||||||
assert dropdown_html =~ field.name
|
|
||||||
assert dropdown_html =~ "cdf_#{field.id}_from"
|
|
||||||
assert dropdown_html =~ "cdf_#{field.id}_to"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
|
|
||||||
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()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#member-filter form", %{
|
|
||||||
"jd_from" => "2024-01-15",
|
|
||||||
"payment_filter" => "all"
|
|
||||||
})
|
|
||||||
|> render_change()
|
|
||||||
|
|
||||||
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
|
|
||||||
path = assert_patch(view)
|
|
||||||
assert path =~ "jd_from=2024-01-15"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
today = Date.utc_today()
|
|
||||||
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
|
|
||||||
|
|
||||||
{:ok, former} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: unique_name,
|
|
||||||
last_name: "Exited",
|
|
||||||
email: "ex-#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: Date.add(today, -1000),
|
|
||||||
exit_date: Date.add(today, -30)
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Fresh load hides the former member.
|
|
||||||
refute html =~ former.first_name
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> form("#member-filter form", %{
|
|
||||||
"ed_mode" => "all",
|
|
||||||
"payment_filter" => "all"
|
|
||||||
})
|
|
||||||
|> render_change()
|
|
||||||
|
|
||||||
path = assert_patch(view)
|
|
||||||
assert path =~ "ed_mode=all"
|
|
||||||
|
|
||||||
# Now Eve appears in the rendered list.
|
|
||||||
assert render(view) =~ former.first_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
# Create 15 boolean custom fields (more than typical, should trigger scrollbar)
|
|
||||||
boolean_fields =
|
|
||||||
Enum.map(1..15, fn i ->
|
|
||||||
create_boolean_custom_field(%{name: "Field #{i}"})
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Open dropdown
|
|
||||||
view
|
|
||||||
|> element("#member-filter button[aria-haspopup='true']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Extract dropdown panel HTML
|
|
||||||
dropdown_html =
|
|
||||||
view
|
|
||||||
|> element("#member-filter div[role='dialog']")
|
|
||||||
|> render()
|
|
||||||
|
|
||||||
# Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
|
|
||||||
# Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
|
|
||||||
assert dropdown_html =~ "max-h-60"
|
|
||||||
assert dropdown_html =~ "overflow-y-auto"
|
|
||||||
|
|
||||||
# Verify all fields are present in the dropdown
|
|
||||||
Enum.each(boolean_fields, fn field ->
|
|
||||||
assert dropdown_html =~ field.name
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -113,6 +113,74 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# §2.4 — single-source exit-date state (quick ↔ detail) ----------------
|
||||||
|
#
|
||||||
|
# The active/former quick filter is a shortcut onto the very same
|
||||||
|
# `exit_date` state the detailed control writes: there is no separate
|
||||||
|
# quick-filter field. This property drives interleaved sequences of quick
|
||||||
|
# and detailed changes and asserts that `quick_state/1` is always a pure
|
||||||
|
# function of the single `exit_date` source — i.e. the two views can never
|
||||||
|
# diverge.
|
||||||
|
|
||||||
|
defp quick_choice_gen, do: member_of([:active, :former, :all])
|
||||||
|
|
||||||
|
defp command_gen do
|
||||||
|
one_of([
|
||||||
|
gen(all(s <- quick_choice_gen()), do: {:quick, s}),
|
||||||
|
gen all(
|
||||||
|
mode <- exit_date_mode_gen(),
|
||||||
|
from <- optional_date_gen(),
|
||||||
|
to <- optional_date_gen()
|
||||||
|
) do
|
||||||
|
{:detail, %{mode: mode, from: from, to: to}}
|
||||||
|
end
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_command(filters, {:quick, s}), do: DateFilter.set_quick_state(filters, s)
|
||||||
|
defp apply_command(filters, {:detail, exit_date}), do: Map.put(filters, :exit_date, exit_date)
|
||||||
|
|
||||||
|
defp expected_quick_for(:active_only), do: :active
|
||||||
|
defp expected_quick_for(:inactive_only), do: :former
|
||||||
|
defp expected_quick_for(:all), do: :all
|
||||||
|
defp expected_quick_for(:custom), do: :custom
|
||||||
|
|
||||||
|
property "quick filter is a single-source shortcut onto exit_date, never divergent" do
|
||||||
|
check all(commands <- list_of(command_gen(), max_length: 12)) do
|
||||||
|
final =
|
||||||
|
Enum.reduce(commands, DateFilter.default(), fn command, filters ->
|
||||||
|
next = apply_command(filters, command)
|
||||||
|
|
||||||
|
# Invariant at every step: quick_state is derived solely from the
|
||||||
|
# exit_date mode — one consistent value, no second source.
|
||||||
|
assert DateFilter.quick_state(next) == expected_quick_for(next.exit_date.mode)
|
||||||
|
|
||||||
|
# A quick command resolves to exactly the chosen state and clears bounds.
|
||||||
|
case command do
|
||||||
|
{:quick, s} ->
|
||||||
|
assert DateFilter.quick_state(next) == s
|
||||||
|
assert next.exit_date.from == nil
|
||||||
|
assert next.exit_date.to == nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
next
|
||||||
|
end)
|
||||||
|
|
||||||
|
# join_date is untouched by any exit_date command (no cross-contamination).
|
||||||
|
assert final.join_date == DateFilter.default().join_date
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "set_quick_state ∘ quick_state round-trips for the three quick states" do
|
||||||
|
check all(s <- quick_choice_gen(), exit_date <- exit_date_state_gen()) do
|
||||||
|
filters = Map.put(DateFilter.default(), :exit_date, exit_date)
|
||||||
|
assert filters |> DateFilter.set_quick_state(s) |> DateFilter.quick_state() == s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
property "encoding then decoding built-in date filter state is identity" do
|
property "encoding then decoding built-in date filter state is identity" do
|
||||||
check all(
|
check all(
|
||||||
join_date <- join_date_state_gen(),
|
join_date <- join_date_state_gen(),
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,11 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
|
||||||
assert payload.query == "ada"
|
assert payload.query == "ada"
|
||||||
assert payload.sort_field == "last_name"
|
assert payload.sort_field == "last_name"
|
||||||
assert payload.sort_order == "desc"
|
assert payload.sort_order == "desc"
|
||||||
|
# Decoupled from the removed overview cycle toggle (§3.3): the export basis
|
||||||
|
# is always the current cycle and no cycle-status row filter is applied,
|
||||||
|
# regardless of any legacy cycle assigns.
|
||||||
assert payload.show_current_cycle == true
|
assert payload.show_current_cycle == true
|
||||||
assert payload.cycle_status_filter == "unpaid"
|
assert payload.cycle_status_filter == nil
|
||||||
assert payload.boolean_filters == %{"cf-x" => true}
|
assert payload.boolean_filters == %{"cf-x" => true}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -79,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
|
||||||
assert payload.query == nil
|
assert payload.query == nil
|
||||||
assert payload.sort_field == nil
|
assert payload.sort_field == nil
|
||||||
assert payload.sort_order == nil
|
assert payload.sort_order == nil
|
||||||
assert payload.show_current_cycle == false
|
assert payload.show_current_cycle == true
|
||||||
assert payload.cycle_status_filter == nil
|
assert payload.cycle_status_filter == nil
|
||||||
assert payload.boolean_filters == %{}
|
assert payload.boolean_filters == %{}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.Index.OverviewQueryCycleStatusTest do
|
|
||||||
@moduledoc """
|
|
||||||
§1.13 — the paid/unpaid cycle-status filter, under the current or last-completed
|
|
||||||
cycle view, returns the same members as the previous in-memory classifier,
|
|
||||||
computed DB-side via the `cycle_end`-backed aggregates.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
|
||||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
|
||||||
|
|
||||||
@today ~D[2024-07-15]
|
|
||||||
|
|
||||||
setup do
|
|
||||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
ft = create_fee_type(%{interval: :yearly}, actor)
|
|
||||||
|
|
||||||
# current 2024 paid, last-completed 2023 unpaid
|
|
||||||
paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
|
||||||
create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
|
|
||||||
create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
|
|
||||||
|
|
||||||
# current 2024 unpaid, last-completed 2023 paid
|
|
||||||
unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
|
||||||
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
|
|
||||||
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
|
|
||||||
|
|
||||||
# no cycles
|
|
||||||
_no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
|
||||||
|
|
||||||
%{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp db_ids(status, show_current, actor) do
|
|
||||||
OverviewQuery.build(%{
|
|
||||||
cycle_status_filter: status,
|
|
||||||
show_current_cycle: show_current,
|
|
||||||
today: @today
|
|
||||||
})
|
|
||||||
|> Ash.read!(actor: actor)
|
|
||||||
|> MapSet.new(& &1.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp oracle_ids(status, show_current, actor) do
|
|
||||||
Member
|
|
||||||
|> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
|
|
||||||
|> Ash.Query.load(:membership_fee_type)
|
|
||||||
|> Ash.read!(actor: actor)
|
|
||||||
|> Enum.filter(fn m ->
|
|
||||||
MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
|
|
||||||
end)
|
|
||||||
|> MapSet.new(& &1.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# {status, show_current, member-key expected in the result}
|
|
||||||
@cases [
|
|
||||||
{:paid, true, :paid_now},
|
|
||||||
{:unpaid, true, :unpaid_now},
|
|
||||||
{:paid, false, :unpaid_now},
|
|
||||||
{:unpaid, false, :paid_now}
|
|
||||||
]
|
|
||||||
|
|
||||||
for {status, show_current, expected_key} <- @cases do
|
|
||||||
test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
|
|
||||||
%{actor: actor} = ctx do
|
|
||||||
status = unquote(status)
|
|
||||||
show_current = unquote(show_current)
|
|
||||||
expected = Map.fetch!(ctx, unquote(expected_key))
|
|
||||||
|
|
||||||
db = db_ids(status, show_current, actor)
|
|
||||||
assert db == oracle_ids(status, show_current, actor)
|
|
||||||
assert expected.id in db
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
156
test/mv_web/member_live/index_active_former_test.exs
Normal file
156
test/mv_web/member_live/index_active_former_test.exs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexActiveFormerTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.7 — the active/former quick filter is a three-state shortcut onto the
|
||||||
|
shared `exit_date` state:
|
||||||
|
|
||||||
|
* "Former" → members with a past exit date (`ed_mode=inactive_only`)
|
||||||
|
* "Active" → members with no or a future exit date (default / `active_only`)
|
||||||
|
* "All" → every member (`ed_mode=all`)
|
||||||
|
|
||||||
|
These assert the *result semantics* through the live overview and its URL
|
||||||
|
contract, which the quick-filter UI drives; they are independent of the
|
||||||
|
particular chip control and survive the builder rework.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
|
active = Fixtures.member_fixture(%{first_name: "Ava", last_name: "Active", exit_date: nil})
|
||||||
|
|
||||||
|
future =
|
||||||
|
Fixtures.member_fixture(%{
|
||||||
|
first_name: "Finn",
|
||||||
|
last_name: "Future",
|
||||||
|
exit_date: Date.add(Date.utc_today(), 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
former =
|
||||||
|
Fixtures.member_fixture(%{
|
||||||
|
first_name: " Former",
|
||||||
|
last_name: "Past",
|
||||||
|
exit_date: Date.add(Date.utc_today(), -30)
|
||||||
|
})
|
||||||
|
|
||||||
|
%{conn: conn, active: active, future: future, former: former}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Former shows only members with a past exit date", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, "/members?ed_mode=inactive_only")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Active shows members with no or a future exit date", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, "/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
assert has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "All shows every member regardless of exit date", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, "/members?ed_mode=all")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
assert has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "as-of-date paragraph (§1.24)" do
|
||||||
|
test "the active/former control carries a separate 'active on reference date' input", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
|
# The as-of-date input sits in its own paragraph, not inside the
|
||||||
|
# Active/Former button-join.
|
||||||
|
assert has_element?(view, "[data-testid='as-of-date-paragraph'] input[name='as_of_date']")
|
||||||
|
refute has_element?(view, ".join input[name='as_of_date']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "setting a reference date activates the point-in-time membership filter", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-active-former']")
|
||||||
|
|> render_change(%{"quick" => "active", "as_of_date" => "2024-06-15"})
|
||||||
|
|
||||||
|
_ = render(view)
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "as_of_date=2024-06-15"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "custom exit-date range (§1.24)" do
|
||||||
|
test "the exit-date control carries a separate native From/To range paragraph", ctx do
|
||||||
|
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
|
# Third paragraph, same native two-input range control the join-date
|
||||||
|
# builder uses — separate from the Active/Former join and the as-of-date box.
|
||||||
|
assert has_element?(view, "[data-testid='value-control-exit-range']")
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='value-control-exit-range'] input[type='date'][name='ed_from']"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='value-control-exit-range'] input[type='date'][name='ed_to']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "typing an exit-date range commits ed_mode=custom with bounds and filters to it", ctx do
|
||||||
|
from = Date.add(Date.utc_today(), -60)
|
||||||
|
to = Date.add(Date.utc_today(), -1)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-exit-range']")
|
||||||
|
|> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
|
||||||
|
|
||||||
|
_ = render(view)
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "ed_mode=custom"
|
||||||
|
assert path =~ "ed_from=#{Date.to_iso8601(from)}"
|
||||||
|
assert path =~ "ed_to=#{Date.to_iso8601(to)}"
|
||||||
|
|
||||||
|
# Only the member whose exit date falls within [from, to] is shown.
|
||||||
|
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.active.id}")
|
||||||
|
refute has_element?(view, "#row-#{ctx.future.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an exit-date range preserves an existing join-date filter", ctx do
|
||||||
|
from = Date.add(Date.utc_today(), -60)
|
||||||
|
to = Date.add(Date.utc_today(), -1)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(ctx.conn, ~p"/members?jd_from=2020-01-01")
|
||||||
|
|
||||||
|
pick_field(view, "active_former")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-exit-range']")
|
||||||
|
|> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
|
||||||
|
|
||||||
|
_ = render(view)
|
||||||
|
path = assert_patch(view)
|
||||||
|
# The exit range must not wipe the pre-existing join-date bound.
|
||||||
|
assert path =~ "jd_from=2020-01-01"
|
||||||
|
assert path =~ "ed_mode=custom"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
115
test/mv_web/member_live/index_add_filter_flow_test.exs
Normal file
115
test/mv_web/member_live/index_add_filter_flow_test.exs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexAddFilterFlowTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.1–§1.3, §1.11, §3.1 — the Polaris-style add-filter flow of the
|
||||||
|
`AddFilterBuilderComponent`: field pick → focused value control →
|
||||||
|
commit-on-select / drop-on-dismiss, with no detail "Add" button, and the
|
||||||
|
removal of the old vertical filter panel.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Membership.Group
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Flow", last_name: "Member", email: "flow@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "Board"})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, _mg} =
|
||||||
|
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), member: member, group: group}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the add-filter builder replaces the old vertical filter panel (§1.11)", %{conn: conn} do
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='add-filter-trigger']")
|
||||||
|
# No residue of the removed MemberFilterComponent.
|
||||||
|
refute html =~ ~s(data-testid="member-filter-form")
|
||||||
|
refute html =~ "member-filter-dropdown"
|
||||||
|
refute has_element?(view, ~s(button[aria-label="Filter members"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "picking a field closes the picker and opens a focused value control, no Add button (§1.1)",
|
||||||
|
%{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_picker(view)
|
||||||
|
assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
|
||||||
|
|
||||||
|
view |> element("#member-filter-field-opt-group") |> render_click()
|
||||||
|
|
||||||
|
# Picker closes, the group value control opens; there is no detail "Add" button.
|
||||||
|
refute has_element?(view, "#member-filter-field-listbox")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group']")
|
||||||
|
refute has_element?(view, "[data-testid='pending-filter'] button[type='submit']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a valid value selection commits a chip and reloads (§1.2)", %{conn: conn, group: group} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
path = apply_group_filter(view, group.id, "in")
|
||||||
|
|
||||||
|
assert path =~ "group_#{group.id}=in"
|
||||||
|
assert has_element?(view, "[data-testid='filter-chip']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "dismissing a pending filter without a selection drops it, no chip, no reload (§1.3)",
|
||||||
|
%{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "group")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group']")
|
||||||
|
|
||||||
|
# No chip has been added and no navigation happened yet (pending, uncommitted).
|
||||||
|
refute has_element?(view, "[data-testid='filter-chip']")
|
||||||
|
refute_patched(view)
|
||||||
|
|
||||||
|
# Dismiss (Escape/click-away) drops the pending control without committing.
|
||||||
|
render_keydown(element(view, "#member-filter"), %{"key" => "Escape"})
|
||||||
|
refute has_element?(view, "[data-testid='value-control-group']")
|
||||||
|
refute has_element?(view, "[data-testid='filter-chip']")
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "§3.7 join-toggles are keyboard-operable (WCAG 2.1.1 / 2.4.7)" do
|
||||||
|
test "the shared toggle radios are focusable (not display:none) with a visible focus ring",
|
||||||
|
%{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "group")
|
||||||
|
|
||||||
|
scope = "[data-testid='value-control-group']"
|
||||||
|
|
||||||
|
assert has_element?(view, "#{scope} input[type='radio']"),
|
||||||
|
"expected the join-toggle to render native radio inputs"
|
||||||
|
|
||||||
|
# A `hidden`/`display:none` (or negative-tabindex) input is skipped by Tab
|
||||||
|
# and cannot be reached with the keyboard — the WCAG 2.1.1 failure.
|
||||||
|
refute has_element?(view, "#{scope} input[type='radio'].hidden"),
|
||||||
|
"toggle radio must not be display:none-hidden (keyboard-unreachable)"
|
||||||
|
|
||||||
|
refute has_element?(view, "#{scope} input[type='radio'][tabindex='-1']"),
|
||||||
|
"toggle radio must stay in the natural tab order (no tabindex=-1)"
|
||||||
|
|
||||||
|
# WCAG 2.4.7: the styled label must surface a visible focus indicator when
|
||||||
|
# its (visually-hidden) input is keyboard-focused.
|
||||||
|
assert render(element(view, scope)) =~ "focus-visible",
|
||||||
|
"toggle label must carry a :focus-visible ring indicator"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp refute_patched(view) do
|
||||||
|
assert_raise ArgumentError, fn -> assert_patch(view, 50) end
|
||||||
|
end
|
||||||
|
end
|
||||||
78
test/mv_web/member_live/index_as_of_date_property_test.exs
Normal file
78
test/mv_web/member_live/index_as_of_date_property_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexAsOfDatePropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.6 — Point-in-time membership ("member on the reference date X").
|
||||||
|
|
||||||
|
For arbitrary (join_date, exit_date) pairs and reference dates X, the as-of-date
|
||||||
|
filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`.
|
||||||
|
Verified at the `OverviewQuery` layer against an in-memory oracle.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
@dates [
|
||||||
|
~D[2023-01-01],
|
||||||
|
~D[2024-06-15],
|
||||||
|
~D[2025-01-01],
|
||||||
|
~D[2025-06-15],
|
||||||
|
~D[2026-01-01]
|
||||||
|
]
|
||||||
|
|
||||||
|
defp clear(actor) do
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp on_date?(join, exit_date, x) do
|
||||||
|
Date.compare(join, x) != :gt and (is_nil(exit_date) or Date.compare(exit_date, x) == :gt)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generates a (join_date, exit_date) pair where exit_date is nil or on/after
|
||||||
|
# join_date, so it satisfies the Member "exit not before join" validation.
|
||||||
|
defp valid_pair_gen do
|
||||||
|
StreamData.bind(StreamData.member_of(@dates), fn join ->
|
||||||
|
later = [nil | Enum.filter(@dates, &(Date.compare(&1, join) == :gt))]
|
||||||
|
|
||||||
|
StreamData.bind(StreamData.member_of(later), fn exit_date ->
|
||||||
|
StreamData.constant({join, exit_date})
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
property "as-of-date filter keeps exactly the members active on X" do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
check all(
|
||||||
|
specs <- StreamData.list_of(valid_pair_gen(), min_length: 1, max_length: 5),
|
||||||
|
x <- StreamData.member_of(@dates),
|
||||||
|
max_runs: 25
|
||||||
|
) do
|
||||||
|
clear(actor)
|
||||||
|
|
||||||
|
created =
|
||||||
|
Enum.map(specs, fn {join, exit_date} ->
|
||||||
|
member =
|
||||||
|
member_fixture_with_actor(%{join_date: join, exit_date: exit_date}, actor)
|
||||||
|
|
||||||
|
{member.id, join, exit_date}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expected =
|
||||||
|
for {id, join, exit_date} <- created,
|
||||||
|
on_date?(join, exit_date, x),
|
||||||
|
into: MapSet.new(),
|
||||||
|
do: id
|
||||||
|
|
||||||
|
actual =
|
||||||
|
%{as_of_date: x}
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
|
||||||
|
assert MapSet.equal?(actual, expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
91
test/mv_web/member_live/index_chip_edit_test.exs
Normal file
91
test/mv_web/member_live/index_chip_edit_test.exs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexChipEditTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.18 — clicking an applied-filter chip body re-opens that filter's value
|
||||||
|
control pre-filled with the current value; a changed selection re-commits and
|
||||||
|
updates the chip; the × still removes the filter.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Membership.Group
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group} =
|
||||||
|
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, _mg} =
|
||||||
|
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), group: group}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clicking the chip body opens the value control pre-filled with the current value", %{
|
||||||
|
conn: conn,
|
||||||
|
group: group
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid='value-control-group']")
|
||||||
|
|
||||||
|
view |> element("[data-testid='filter-chip-edit']") |> render_click()
|
||||||
|
|
||||||
|
# The group control opens with this group's "is" (in) operator pre-selected.
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group']")
|
||||||
|
assert has_element?(view, "input[name='group_#{group.id}'][value='in'][checked]")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the value control opens anchored under the edited chip, not as a new pending chip", %{
|
||||||
|
conn: conn,
|
||||||
|
group: group
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||||
|
|
||||||
|
view |> element("[data-testid='filter-chip-edit']") |> render_click()
|
||||||
|
|
||||||
|
# §1.18: the control is a descendant of the chip's own list item (anchored in
|
||||||
|
# place under that chip), and no separate pending chip is spun up at the row.
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='filter-chip-item'] [data-testid='value-control-group']"
|
||||||
|
)
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid='pending-chip']")
|
||||||
|
refute has_element?(view, "[data-testid='pending-filter']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "changing the value from the re-opened control re-commits and updates the URL", %{
|
||||||
|
conn: conn,
|
||||||
|
group: group
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||||
|
|
||||||
|
view |> element("[data-testid='filter-chip-edit']") |> render_click()
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-group']")
|
||||||
|
|> render_change(%{"group_#{group.id}" => "not_in"})
|
||||||
|
|
||||||
|
_ = render(view)
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "group_#{group.id}=not_in"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the × still removes the filter", %{conn: conn, group: group} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||||
|
|
||||||
|
view |> element("[data-testid='filter-chip-remove']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
refute path =~ "group_#{group.id}"
|
||||||
|
refute has_element?(view, "[data-testid='filter-chip']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -23,9 +23,9 @@ defmodule MvWeb.MemberLive.IndexColumnManagerTest do
|
||||||
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
|
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/members")
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
# Both the filter panel trigger and the column-manager trigger render in the
|
# Both the add-filter builder trigger and the column-manager trigger render
|
||||||
# same toolbar.
|
# in the same toolbar.
|
||||||
assert has_element?(view, ~s(button[aria-label="Filter members"]))
|
assert has_element?(view, "[data-testid='add-filter-trigger']")
|
||||||
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
|
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
66
test/mv_web/member_live/index_date_presets_property_test.exs
Normal file
66
test/mv_web/member_live/index_date_presets_property_test.exs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDatePresetsPropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.8 — Relative date-range preset resolution.
|
||||||
|
|
||||||
|
For any preset evaluated against any reference date "today", the resolved
|
||||||
|
`{from, to}` matches the preset's definition: rolling "last N days" windows end
|
||||||
|
on today, and the "this month/quarter/year" presets run period-to-date from the
|
||||||
|
respective period start to today.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.DatePresets
|
||||||
|
|
||||||
|
defp date_gen do
|
||||||
|
gen all(
|
||||||
|
year <- StreamData.integer(2000..2100),
|
||||||
|
month <- StreamData.integer(1..12),
|
||||||
|
day <- StreamData.integer(1..28)
|
||||||
|
) do
|
||||||
|
Date.new!(year, month, day)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "last-N-days presets end on today and span N days inclusive" do
|
||||||
|
check all(today <- date_gen()) do
|
||||||
|
assert DatePresets.resolve(:last_7_days, today) == %{from: Date.add(today, -6), to: today}
|
||||||
|
assert DatePresets.resolve(:last_30_days, today) == %{from: Date.add(today, -29), to: today}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "last-N-months presets shift the start back N months and end on today" do
|
||||||
|
check all(today <- date_gen()) do
|
||||||
|
assert DatePresets.resolve(:last_3_months, today) ==
|
||||||
|
%{from: Date.shift(today, month: -3), to: today}
|
||||||
|
|
||||||
|
assert DatePresets.resolve(:last_12_months, today) ==
|
||||||
|
%{from: Date.shift(today, month: -12), to: today}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "this-period presets run from the period start to today" do
|
||||||
|
check all(today <- date_gen()) do
|
||||||
|
month_start = %{today | day: 1}
|
||||||
|
q_first_month = div(today.month - 1, 3) * 3 + 1
|
||||||
|
quarter_start = Date.new!(today.year, q_first_month, 1)
|
||||||
|
year_start = Date.new!(today.year, 1, 1)
|
||||||
|
|
||||||
|
assert DatePresets.resolve(:this_month, today) == %{from: month_start, to: today}
|
||||||
|
assert DatePresets.resolve(:this_quarter, today) == %{from: quarter_start, to: today}
|
||||||
|
assert DatePresets.resolve(:this_year, today) == %{from: year_start, to: today}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "all/0 lists every resolvable preset key in display order" do
|
||||||
|
assert DatePresets.all() == [
|
||||||
|
:last_7_days,
|
||||||
|
:last_30_days,
|
||||||
|
:last_3_months,
|
||||||
|
:last_12_months,
|
||||||
|
:this_month,
|
||||||
|
:this_quarter,
|
||||||
|
:this_year
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
51
test/mv_web/member_live/index_date_presets_test.exs
Normal file
51
test/mv_web/member_live/index_date_presets_test.exs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.25 (supersedes §1.8) — a date field's value control offers relative
|
||||||
|
presets; picking "This year" applies the period-to-date range (Jan 1 – today)
|
||||||
|
of the current year and writes the equivalent range params to the URL.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Preset", last_name: "Member", email: "preset@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the 'This year' preset applies the year-to-date range and writes URL params", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Open the Join date value control and apply the "This year" preset.
|
||||||
|
pick_field(view, "join_date")
|
||||||
|
assert has_element?(view, "[data-testid='date-presets']")
|
||||||
|
|
||||||
|
view |> element("[data-testid='date-preset-this_year']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
today = Date.utc_today()
|
||||||
|
assert path =~ "jd_from=#{today.year}-01-01"
|
||||||
|
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the 'Last 30 days' preset applies a today-anchored 30-day window", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "join_date")
|
||||||
|
view |> element("[data-testid='date-preset-last_30_days']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
today = Date.utc_today()
|
||||||
|
assert path =~ "jd_from=#{Date.to_iso8601(Date.add(today, -29))}"
|
||||||
|
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
74
test/mv_web/member_live/index_date_range_test.exs
Normal file
74
test/mv_web/member_live/index_date_range_test.exs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDateRangeTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.25 / §3.12 (native-first) — the date-range value control combines the
|
||||||
|
relative presets with a separate "Eigener Zeitraum" (custom range) paragraph
|
||||||
|
holding two native `<input type="date">` fields (Von/Bis). cally is removed.
|
||||||
|
|
||||||
|
Picking a preset commits a concrete range; typing into the native Von/Bis
|
||||||
|
inputs commits the custom range through the shared date-filter dispatch.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Range", last_name: "Member", email: "range@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the join-date control shows presets and native Von/Bis date inputs", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "join_date")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='value-control-date-range']")
|
||||||
|
assert has_element?(view, "[data-testid='date-presets']")
|
||||||
|
# Separate "Eigener Zeitraum" paragraph with two native date inputs side by side.
|
||||||
|
assert has_element?(view, "[data-testid='custom-range']")
|
||||||
|
assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_from']")
|
||||||
|
assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_to']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cally is fully removed from the date-range control", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "join_date")
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid='date-range-cally']")
|
||||||
|
refute has_element?(view, "calendar-range")
|
||||||
|
refute has_element?(view, "[phx-hook='DateRangeCally']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "picking a preset commits a concrete range to the URL", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "join_date")
|
||||||
|
view |> element("[data-testid='date-preset-this_year']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
today = Date.utc_today()
|
||||||
|
assert path =~ "jd_from=#{today.year}-01-01"
|
||||||
|
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "typing a native custom range commits it to the URL", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "join_date")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-date-range']")
|
||||||
|
|> render_change(%{"jd_from" => "2024-03-01", "jd_to" => "2024-03-31"})
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "jd_from=2024-03-01"
|
||||||
|
assert path =~ "jd_to=2024-03-31"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -40,7 +40,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
||||||
# Fee type, fee status, groups, join date headers.
|
# Fee type, fee status, groups, join date headers.
|
||||||
assert has_element?(view, "[data-testid='membership_fee_type']")
|
assert has_element?(view, "[data-testid='membership_fee_type']")
|
||||||
assert has_element?(view, "[data-testid='join_date']")
|
assert has_element?(view, "[data-testid='join_date']")
|
||||||
assert has_element?(view, "th", "Membership Fee Status")
|
# The former "Membership Fee Status" column is now the period-scoped payment
|
||||||
|
# aging column (§3.3), headed "Fees".
|
||||||
|
assert has_element?(view, "th", "Fees")
|
||||||
assert has_element?(view, "[data-testid='groups']")
|
assert has_element?(view, "[data-testid='groups']")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
82
test/mv_web/member_live/index_field_picker_test.exs
Normal file
82
test/mv_web/member_live/index_field_picker_test.exs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFieldPickerTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.10 / §3.5 — the FilterDescriptor catalog behind the field picker.
|
||||||
|
|
||||||
|
Fields are grouped Quick / Membership / Custom fields; a group with no
|
||||||
|
available fields is omitted (renders no header). The catalog is derived
|
||||||
|
purely from the field context and is serializable (the persistence basis for
|
||||||
|
saved views, #549).
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.FilterDescriptor
|
||||||
|
|
||||||
|
defp group_keys(descriptors, group) do
|
||||||
|
descriptors
|
||||||
|
|> Enum.filter(&(&1.group == group))
|
||||||
|
|> Enum.map(& &1.key)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "groups are ordered Quick / Membership / Custom fields" do
|
||||||
|
context = %{
|
||||||
|
groups: [%{id: Ecto.UUID.generate(), name: "Board"}],
|
||||||
|
fee_types: [%{id: Ecto.UUID.generate(), name: "Standard"}],
|
||||||
|
custom_fields: [%{id: Ecto.UUID.generate(), name: "Newsletter", value_type: :boolean}]
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptors = FilterDescriptor.all(context)
|
||||||
|
|
||||||
|
assert Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) ==
|
||||||
|
[:quick, :membership, :custom_fields]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "quick and membership fields are present with type-aware controls" do
|
||||||
|
descriptors = FilterDescriptor.all(%{groups: [%{id: Ecto.UUID.generate(), name: "G"}]})
|
||||||
|
|
||||||
|
assert group_keys(descriptors, :quick) == [:payment, :active_former]
|
||||||
|
assert :group in group_keys(descriptors, :membership)
|
||||||
|
assert :join_date in group_keys(descriptors, :membership)
|
||||||
|
assert :exit_date in group_keys(descriptors, :membership)
|
||||||
|
|
||||||
|
payment = Enum.find(descriptors, &(&1.key == :payment))
|
||||||
|
assert payment.control == :payment_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty custom-fields group is omitted (no header)" do
|
||||||
|
descriptors = FilterDescriptor.all(%{custom_fields: []})
|
||||||
|
|
||||||
|
groups = Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0))
|
||||||
|
refute :custom_fields in groups
|
||||||
|
end
|
||||||
|
|
||||||
|
test "membership omits Group/Fee type when the club has none" do
|
||||||
|
descriptors = FilterDescriptor.all(%{groups: [], fee_types: []})
|
||||||
|
|
||||||
|
membership_keys = group_keys(descriptors, :membership)
|
||||||
|
refute :group in membership_keys
|
||||||
|
refute :fee_type in membership_keys
|
||||||
|
# Join/exit date are always available.
|
||||||
|
assert :join_date in membership_keys
|
||||||
|
assert :exit_date in membership_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
test "one custom-fields descriptor per filterable field; non-filterable types dropped" do
|
||||||
|
date_id = Ecto.UUID.generate()
|
||||||
|
bool_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
context = %{
|
||||||
|
custom_fields: [
|
||||||
|
%{id: date_id, name: "Birthday", value_type: :date},
|
||||||
|
%{id: bool_id, name: "Consent", value_type: :boolean},
|
||||||
|
%{id: Ecto.UUID.generate(), name: "Phone", value_type: :string}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
custom = Enum.filter(FilterDescriptor.all(context), &(&1.group == :custom_fields))
|
||||||
|
|
||||||
|
assert Enum.map(custom, & &1.key) == [to_string(date_id), to_string(bool_id)]
|
||||||
|
# Keys are plain strings — serializable for #549.
|
||||||
|
assert Enum.all?(custom, &is_binary(&1.key))
|
||||||
|
assert Enum.find(custom, &(&1.key == to_string(date_id))).control == :date_range
|
||||||
|
end
|
||||||
|
end
|
||||||
92
test/mv_web/member_live/index_filter_chips_test.exs
Normal file
92
test/mv_web/member_live/index_filter_chips_test.exs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFilterChipsTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.4, §1.5, §3.2 — applied-filter chips render one per value, each is an
|
||||||
|
independently removable real button, and "Clear all" removes every active
|
||||||
|
filter in a single reload with the URL reset to the unfiltered state.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Membership.Group
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Chip", last_name: "Member", email: "chip@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group1} =
|
||||||
|
Group |> Ash.Changeset.for_create(:create, %{name: "Alpha"}) |> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, group2} =
|
||||||
|
Group |> Ash.Changeset.for_create(:create, %{name: "Beta"}) |> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, _mg} =
|
||||||
|
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), group1: group1, group2: group2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an applied filter renders a removable chip that is a real button (§1.4, §3.2)", %{
|
||||||
|
conn: conn,
|
||||||
|
group1: group1
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
|
||||||
|
|
||||||
|
# The chip's remove control is a real button whose accessible name names the
|
||||||
|
# filter it removes (locale-independent: it contains the group name).
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"button[data-testid='filter-chip-remove'][aria-label*='Alpha']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "removing a chip drops its URL param and reloads (§1.4)", %{conn: conn, group1: group1} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
|
||||||
|
|
||||||
|
view |> element("[data-testid='filter-chip-remove']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
refute path =~ "group_#{group1.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Clear all removes every active filter in one reload and resets the URL (§1.5)", %{
|
||||||
|
conn: conn,
|
||||||
|
group1: group1,
|
||||||
|
group2: group2
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='clear-all-filters']")
|
||||||
|
view |> element("[data-testid='clear-all-filters']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
refute path =~ "group_#{group1.id}"
|
||||||
|
refute path =~ "group_#{group2.id}"
|
||||||
|
refute has_element?(view, "[data-testid='filter-chip']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "each value renders its own chip (one chip per value)", %{
|
||||||
|
conn: conn,
|
||||||
|
group1: group1,
|
||||||
|
group2: group2
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
|
||||||
|
|
||||||
|
chips =
|
||||||
|
render(view)
|
||||||
|
|> LazyHTML.from_fragment()
|
||||||
|
|> LazyHTML.query(~s([data-testid="filter-chip"]))
|
||||||
|
|> Enum.count()
|
||||||
|
|
||||||
|
assert chips == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFilterHeightPropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.3 — the resting filter UI's size depends only on the number of active
|
||||||
|
filters, never on the size of the available-field catalog. The builder renders
|
||||||
|
one chip per active filter regardless of how many groups/fee types/custom
|
||||||
|
fields are pickable, so the chip count is a function of the active-filter count
|
||||||
|
alone.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias MvWeb.Components.AddFilterBuilderComponent
|
||||||
|
|
||||||
|
defp group(i), do: %{id: "g#{i}", name: "Group #{i}"}
|
||||||
|
|
||||||
|
defp chip_count(html) do
|
||||||
|
html
|
||||||
|
|> LazyHTML.from_fragment()
|
||||||
|
|> LazyHTML.query(~s([data-testid="filter-chip"]))
|
||||||
|
|> Enum.count()
|
||||||
|
end
|
||||||
|
|
||||||
|
property "resting chip count depends only on active-filter count, not catalog size" do
|
||||||
|
check all(
|
||||||
|
catalog_size <- integer(0..8),
|
||||||
|
active_count <- integer(0..5)
|
||||||
|
) do
|
||||||
|
# active filters must reference real catalog ids, so ensure enough groups.
|
||||||
|
total = max(catalog_size, active_count)
|
||||||
|
groups = Enum.map(1..max(total, 1)//1, &group/1)
|
||||||
|
groups = if total == 0, do: [], else: groups
|
||||||
|
|
||||||
|
group_filters =
|
||||||
|
groups
|
||||||
|
|> Enum.take(active_count)
|
||||||
|
|> Map.new(&{&1.id, :in})
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_component(AddFilterBuilderComponent,
|
||||||
|
id: "member-filter",
|
||||||
|
groups: groups,
|
||||||
|
group_filters: group_filters
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chip_count(html) == active_count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
defmodule MvWeb.MemberLive.IndexFilterPanelTest do
|
|
||||||
@moduledoc """
|
|
||||||
§1.18 — The existing "Apply filters" panel is unchanged: same fieldset/legend
|
|
||||||
form structure and payment-status controls, no chip/add-filter-builder
|
|
||||||
paradigm (that is #548). Its filters now resolve DB-side via the `:overview`
|
|
||||||
read action.
|
|
||||||
"""
|
|
||||||
use MvWeb.ConnCase, async: false
|
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
setup %{conn: conn} do
|
|
||||||
{:ok, _} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{first_name: "Panel", last_name: "Member", email: "panel@example.com"},
|
|
||||||
actor: SystemActor.get_system_actor()
|
|
||||||
)
|
|
||||||
|
|
||||||
%{conn: conn_with_oidc_user(conn)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp open_filter(view) do
|
|
||||||
view
|
|
||||||
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|
|
||||||
|> render_click()
|
|
||||||
end
|
|
||||||
|
|
||||||
test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/members")
|
|
||||||
open_filter(view)
|
|
||||||
|
|
||||||
# Semantic form structure (fieldset/legend, radio controls) is preserved.
|
|
||||||
assert has_element?(view, ~s(form[data-testid="member-filter-form"]))
|
|
||||||
assert has_element?(view, "#payment-filter-all")
|
|
||||||
assert has_element?(view, "#payment-filter-paid")
|
|
||||||
assert has_element?(view, "#payment-filter-unpaid")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do
|
|
||||||
{:ok, view, html} = live(conn, ~p"/members")
|
|
||||||
open_filter(view)
|
|
||||||
html = html <> render(view)
|
|
||||||
|
|
||||||
refute html =~ ~s(data-testid="add-filter")
|
|
||||||
refute html =~ ~s(data-testid="filter-chip")
|
|
||||||
refute html =~ "filter-builder"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor)
|
|
||||||
last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1)
|
|
||||||
|
|
||||||
{:ok, paid} =
|
|
||||||
Mv.Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "PaidPanel",
|
|
||||||
last_name: "X",
|
|
||||||
email: "paidpanel@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
Mv.Fixtures.create_cycle(
|
|
||||||
paid,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid")
|
|
||||||
|
|
||||||
assert html =~ "PaidPanel"
|
|
||||||
refute html =~ "Panel Member"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
|
|
||||||
The unchanged DB filters (group, fee-type) are applied identically in both
|
The unchanged DB filters (group, fee-type) are applied identically in both
|
||||||
arms; the property isolates the filters that moved from memory to the DB
|
arms; the property isolates the filters that moved from memory to the DB
|
||||||
(boolean custom fields, date custom fields, paid/unpaid cycle status) by
|
(boolean custom fields, date custom fields) plus the period-scoped payment
|
||||||
applying them DB-side in one arm and in-memory in the other.
|
aging filter, by applying them DB-side in one arm and in-memory in the other.
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
use ExUnitProperties
|
use ExUnitProperties
|
||||||
|
|
@ -21,10 +21,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias MvWeb.MemberLive.Index
|
alias MvWeb.MemberLive.Index
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
|
||||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
@today ~D[2024-07-15]
|
|
||||||
@cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]]
|
@cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]]
|
||||||
@date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]]
|
@date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]]
|
||||||
|
|
||||||
|
|
@ -74,8 +72,14 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
|
|
||||||
defp filter_params_gen do
|
defp filter_params_gen do
|
||||||
StreamData.fixed_map(%{
|
StreamData.fixed_map(%{
|
||||||
cycle_status: StreamData.member_of([nil, :paid, :unpaid]),
|
payment:
|
||||||
show_current: StreamData.boolean(),
|
StreamData.member_of([
|
||||||
|
nil,
|
||||||
|
:fully_paid,
|
||||||
|
{:has_unpaid, 1},
|
||||||
|
{:has_unpaid, 2},
|
||||||
|
{:has_unpaid, 3}
|
||||||
|
]),
|
||||||
bool: StreamData.member_of([nil, true, false]),
|
bool: StreamData.member_of([nil, true, false]),
|
||||||
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
|
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
|
||||||
group: StreamData.member_of([nil, :in, :not_in]),
|
group: StreamData.member_of([nil, :in, :not_in]),
|
||||||
|
|
@ -144,16 +148,16 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
|
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
|
||||||
|
|
||||||
defp moved_opts(params, ctx) do
|
defp moved_opts(params, ctx) do
|
||||||
%{today: @today}
|
%{}
|
||||||
|> put_cycle(params.cycle_status, params.show_current)
|
|> put_payment(params.payment)
|
||||||
|> put_bool(params.bool, ctx)
|
|> put_bool(params.bool, ctx)
|
||||||
|> put_date(params.date_range, ctx)
|
|> put_date(params.date_range, ctx)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp put_cycle(opts, nil, _show), do: opts
|
defp put_payment(opts, nil), do: opts
|
||||||
|
|
||||||
defp put_cycle(opts, status, show),
|
defp put_payment(opts, filter),
|
||||||
do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show})
|
do: Map.merge(opts, %{payment_filter: filter, payment_period: %{from: nil, to: nil}})
|
||||||
|
|
||||||
defp put_bool(opts, nil, _ctx), do: opts
|
defp put_bool(opts, nil, _ctx), do: opts
|
||||||
|
|
||||||
|
|
@ -195,7 +199,7 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
base_members
|
base_members
|
||||||
|> apply_oracle_bool(params, ctx)
|
|> apply_oracle_bool(params, ctx)
|
||||||
|> apply_oracle_date(params, ctx)
|
|> apply_oracle_date(params, ctx)
|
||||||
|> apply_oracle_cycle(params)
|
|> apply_oracle_payment(params)
|
||||||
|> MapSet.new(& &1.id)
|
|> MapSet.new(& &1.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -219,14 +223,22 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members
|
defp apply_oracle_payment(members, %{payment: nil}), do: members
|
||||||
|
|
||||||
defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do
|
defp apply_oracle_payment(members, %{payment: filter}) do
|
||||||
Enum.filter(members, fn m ->
|
Enum.filter(members, fn m -> payment_match?(unpaid_count(m), filter) end)
|
||||||
MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Member-relative count of unpaid cycles (all-time period, so every unpaid
|
||||||
|
# cycle counts); suspended and paid are excluded, mirroring the calculation.
|
||||||
|
defp unpaid_count(member) do
|
||||||
|
member.membership_fee_cycles
|
||||||
|
|> Enum.count(&(&1.status == :unpaid))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp payment_match?(count, :fully_paid), do: count == 0
|
||||||
|
defp payment_match?(count, {:has_unpaid, n}), do: count >= n
|
||||||
|
|
||||||
property "DB-backed filter result set equals the in-memory oracle", ctx do
|
property "DB-backed filter result set equals the in-memory oracle", ctx do
|
||||||
check all(
|
check all(
|
||||||
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),
|
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),
|
||||||
|
|
|
||||||
48
test/mv_web/member_live/index_filter_row_test.exs
Normal file
48
test/mv_web/member_live/index_filter_row_test.exs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFilterRowTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.19 — the filter row renders "+ Add filter", the applied-filter chips and
|
||||||
|
"Clear all" as one uniform button size (all share the `btn-sm` sizing), and
|
||||||
|
each chip is composed of real, focusable buttons.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Membership.Group
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Row", last_name: "Member", email: "row@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group} =
|
||||||
|
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, _mg} =
|
||||||
|
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), group: group}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "add-filter trigger, chips and clear-all share the uniform btn-sm size", %{
|
||||||
|
conn: conn,
|
||||||
|
group: group
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='add-filter-trigger'].btn-sm")
|
||||||
|
assert has_element?(view, "[data-testid='clear-all-filters'].btn-sm")
|
||||||
|
# The chip body and its remove control are real, focusable buttons at btn-sm.
|
||||||
|
assert has_element?(view, "button[data-testid='filter-chip-edit'].btn-sm")
|
||||||
|
assert has_element?(view, "button[data-testid='filter-chip-remove'].btn-sm")
|
||||||
|
|
||||||
|
# The join seam must be a single hairline: the remove button drops its own
|
||||||
|
# left border so the body's right border is the only one at the seam (no
|
||||||
|
# doubled 1px border that sub-pixel-rounds to 1px/2px and flips on zoom).
|
||||||
|
assert has_element?(view, "button[data-testid='filter-chip-remove'].border-l-0")
|
||||||
|
end
|
||||||
|
end
|
||||||
38
test/mv_web/member_live/index_filter_url_roundtrip_test.exs
Normal file
38
test/mv_web/member_live/index_filter_url_roundtrip_test.exs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.1 (extended) — the v2 filter keys survive the LiveView decode→encode round
|
||||||
|
trip. Loading `/members` with an as-of date, the suspended flag, and a
|
||||||
|
two-sided payment count range, then triggering an unrelated re-patch (a sort
|
||||||
|
click), preserves every one of those keys in the pushed URL.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Round", last_name: "Trip", email: "roundtrip@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "as-of date, suspended and payment count range persist through a re-patch", %{conn: conn} do
|
||||||
|
url = "/members?as_of_date=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4"
|
||||||
|
{:ok, view, _html} = live(conn, url)
|
||||||
|
|
||||||
|
# Trigger an unrelated URL re-patch by sorting a stable column.
|
||||||
|
view |> element("[data-testid='payment']") |> render_click()
|
||||||
|
path = assert_patch(view)
|
||||||
|
|
||||||
|
assert path =~ "as_of_date=2026-03-15"
|
||||||
|
assert path =~ "suspended=1"
|
||||||
|
assert path =~ "pay_filter=has_unpaid"
|
||||||
|
assert path =~ "pay_min=2"
|
||||||
|
assert path =~ "pay_max=4"
|
||||||
|
end
|
||||||
|
end
|
||||||
72
test/mv_web/member_live/index_group_or_test.exs
Normal file
72
test/mv_web/member_live/index_group_or_test.exs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexGroupOrTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.29 / §2.7 — Multi-select group semantics is OR.
|
||||||
|
|
||||||
|
Two "is" group selections match members in *either* group (union). Two
|
||||||
|
"is not" selections exclude members in *any* of them. Verified at the
|
||||||
|
`OverviewQuery` layer where the semantics live.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2, group_fixture: 1]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Membership.MemberGroup
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
|
||||||
|
g1 = group_fixture(%{name: "G1-#{System.unique_integer([:positive])}"})
|
||||||
|
g2 = group_fixture(%{name: "G2-#{System.unique_integer([:positive])}"})
|
||||||
|
|
||||||
|
m1 = member_fixture_with_actor(%{}, actor)
|
||||||
|
m2 = member_fixture_with_actor(%{}, actor)
|
||||||
|
m3 = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
add_to_group(m1, g1, actor)
|
||||||
|
add_to_group(m2, g2, actor)
|
||||||
|
|
||||||
|
%{actor: actor, g1: g1, g2: g2, m1: m1, m2: m2, m3: m3}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_to_group(member, group, actor) do
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(opts, actor) do
|
||||||
|
opts
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "two 'is' group selections match members in either group (OR)", ctx do
|
||||||
|
result =
|
||||||
|
ids(
|
||||||
|
%{
|
||||||
|
group_filters: %{to_string(ctx.g1.id) => :in, to_string(ctx.g2.id) => :in},
|
||||||
|
groups: [ctx.g1, ctx.g2]
|
||||||
|
},
|
||||||
|
ctx.actor
|
||||||
|
)
|
||||||
|
|
||||||
|
assert MapSet.equal?(result, MapSet.new([ctx.m1.id, ctx.m2.id]))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "two 'is not' group selections exclude members in any of them", ctx do
|
||||||
|
result =
|
||||||
|
ids(
|
||||||
|
%{
|
||||||
|
group_filters: %{to_string(ctx.g1.id) => :not_in, to_string(ctx.g2.id) => :not_in},
|
||||||
|
groups: [ctx.g1, ctx.g2]
|
||||||
|
},
|
||||||
|
ctx.actor
|
||||||
|
)
|
||||||
|
|
||||||
|
assert MapSet.equal?(result, MapSet.new([ctx.m3.id]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -65,21 +65,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
test "filter dropdown has group presence section with legend", %{
|
test "field picker exposes a combobox with a Group field option", %{
|
||||||
conn: conn
|
conn: conn
|
||||||
} do
|
} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open filter dropdown
|
# Open the add-filter picker (combobox + listbox of grouped fields)
|
||||||
view
|
open_picker(view)
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid='field-combobox'][role='combobox']")
|
||||||
# Groups section: legend "Member has groups" and radios (Any / Yes / No)
|
assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
|
||||||
assert html =~ ~r/[Gg]roups/
|
assert has_element?(view, "#member-filter-field-opt-group[role='option']")
|
||||||
assert has_element?(view, "[data-testid='member-filter-form']")
|
|
||||||
|
# Picking the Group field opens its value control with per-group legends.
|
||||||
|
view |> element("#member-filter-field-opt-group") |> render_click()
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group']")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
|
|
@ -102,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
|
|
@ -140,13 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
||||||
Tests for filtering members by group in the member overview.
|
Tests for filtering members by group in the member overview.
|
||||||
|
|
||||||
Uses the filter dropdown (MemberFilterComponent) with one row per group:
|
Uses the filter dropdown (MemberFilterComponent) with one row per group:
|
||||||
All / Yes / No (per group). Multiple active group filters combine with AND
|
All / Yes / No (per group). Multiple "is" selections combine with OR (union),
|
||||||
(member must match all selected group conditions).
|
while each "is not" selection excludes members in that group (§1.29/§2.7).
|
||||||
"""
|
"""
|
||||||
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||||
|
|
@ -62,20 +62,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp open_filter_and_set_group(view, group_id, value) do
|
defp open_filter_and_set_group(view, group_id, value) do
|
||||||
view
|
path = apply_group_filter(view, group_id, value)
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
key = "group_#{group_id}"
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{key => value, "payment_filter" => "all"})
|
|
||||||
|
|
||||||
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
|
|
||||||
_ = render(view)
|
|
||||||
# Wait for patch; return path so callers can assert URL contains expected filter param
|
|
||||||
path = assert_patch(view)
|
|
||||||
{view, path}
|
{view, path}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
|
|
@ -174,10 +168,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
{:ok, _view, html} =
|
{:ok, _view, html} =
|
||||||
live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
|
live(conn, "/members?group_#{group1.id}=in&pay_filter=fully_paid")
|
||||||
|
|
||||||
assert html =~ "Members"
|
assert html =~ "Members"
|
||||||
# member1 has a group and a paid cycle; page should load with both filters
|
# member1 has a group and no unpaid cycles; both filters keep it visible.
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -191,13 +185,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Apply group filter
|
# Apply group filter
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
# Apply search (this tests that filter and search work together;
|
# Apply search (this tests that filter and search work together;
|
||||||
# search form is in SearchBarComponent with phx-submit="search")
|
# search form is in SearchBarComponent with phx-submit="search")
|
||||||
|
|
@ -242,13 +230,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Apply group filter
|
# Apply group filter
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
# Apply sorting
|
# Apply sorting
|
||||||
view
|
view
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,7 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
|
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
# Force LiveView to process {:group_filter_changed, ...}
|
# Force LiveView to process {:group_filter_changed, ...}
|
||||||
html = render(view)
|
html = render(view)
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
view
|
apply_group_filter(view, group1.id, "in")
|
||||||
|> element("button[aria-label='Filter members']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[data-testid='member-filter-form']")
|
|
||||||
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
|
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for membership fee status column in member list view.
|
Tests for the period-scoped payment aging column in the member overview
|
||||||
|
(§1.13, §3.3). The column shows, per member, the count of unpaid cycles whose
|
||||||
|
`cycle_end` falls in the active period (default: all outstanding); 0 → "Paid";
|
||||||
|
suspended cycles are excluded from the count.
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
defp create_member(attrs) do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
@ -26,253 +25,135 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "status column display" do
|
describe "payment aging column" do
|
||||||
test "shows status column in member list", %{conn: conn} do
|
test "header names the payment column", %{conn: conn} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
assert html =~ "Fees"
|
||||||
|
end
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
test "a member with unpaid cycles shows the unpaid count; suspended excluded", %{conn: conn} do
|
||||||
cycle_start: ~D[2023-01-01],
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
status: :paid,
|
member = create_member(%{first_name: "OwesTwo", membership_fee_type_id: fee_type.id})
|
||||||
replace_existing: true
|
|
||||||
})
|
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :unpaid})
|
||||||
|
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
|
# A suspended cycle must never be counted.
|
||||||
|
create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# Should show membership fee status column
|
assert html =~ "2 open"
|
||||||
assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
|
refute html =~ "3 open"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows last completed cycle status by default", %{conn: conn} do
|
test "a member with no unpaid cycles reads Paid", %{conn: conn} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{first_name: "AllPaid", membership_fee_type_id: fee_type.id})
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||||
cycle_start: ~D[2022-01-01],
|
|
||||||
status: :paid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :unpaid,
|
assert html =~ "Paid"
|
||||||
replace_existing: true
|
end
|
||||||
})
|
|
||||||
|
test "the badge is a real navigation link that drills into the member fees history", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
member = create_member(%{first_name: "Drill", membership_fee_type_id: fee_type.id})
|
||||||
|
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Should show unpaid status (2023 is last completed)
|
badge = "a[data-testid='payment-badge']#payment-badge-#{member.id}"
|
||||||
html = render(view)
|
href = "/members/#{member.id}?tab=membership_fees"
|
||||||
assert html =~ "hero-x-circle" || html =~ "unpaid"
|
|
||||||
|
# A semantic <a> navigating to the member's membership-fees section — not a
|
||||||
|
# div with JS.navigate — so it is keyboard- and right-click-friendly.
|
||||||
|
assert has_element?(view, "#{badge}[href='#{href}']")
|
||||||
|
|
||||||
|
# Its hover/focus tooltip is a top-layer native Popover opened via the
|
||||||
|
# HoverPopover hook (no SortTooltip id-linking on the badge anymore).
|
||||||
|
assert has_element?(view, "#{badge}[phx-hook='HoverPopover']")
|
||||||
|
assert has_element?(view, "#{badge}[data-popover-target='payment-tip-#{member.id}']")
|
||||||
|
refute has_element?(view, "[data-testid='payment-badge'][data-tooltip-id]")
|
||||||
|
|
||||||
|
# The tooltip popover lists the open cycles for the period.
|
||||||
|
assert has_element?(view, "#payment-tip-#{member.id}[popover]")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "toggle switches to current cycle view", %{conn: conn} do
|
test "the tooltip lists suspended cycles in a separate labeled section (§1.14)", %{conn: conn} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{first_name: "Mixed", membership_fee_type_id: fee_type.id})
|
||||||
|
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
today = Date.utc_today()
|
create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
|
||||||
current_year_start = %{today | month: 1, day: 1}
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :paid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: current_year_start,
|
|
||||||
status: :suspended,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Toggle to current cycle (use the button in the header)
|
tip = "#payment-tip-#{member.id}"
|
||||||
view
|
|
||||||
|> element("[data-testid=toggle-cycle-view]")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
html = render(view)
|
# Suspended cycles are shown separately, under their own text label — the
|
||||||
# Should show suspended status (current cycle)
|
# distinction is not color-only. The suspended cycle's period lives in the
|
||||||
assert html =~ "hero-pause-circle" || html =~ "suspended"
|
# suspended section and is absent from the unpaid list.
|
||||||
|
assert has_element?(view, "#{tip} [data-testid='payment-tooltip-suspended']", "Suspended")
|
||||||
|
|
||||||
# The toggle button exposes its pressed state to assistive tech.
|
suspended =
|
||||||
assert has_element?(view, ~s([data-testid=toggle-cycle-view][aria-pressed="true"]))
|
view |> element("#{tip} [data-testid='payment-tooltip-suspended']") |> render()
|
||||||
|
|
||||||
|
assert suspended =~ "2024"
|
||||||
|
|
||||||
|
unpaid = view |> element("#{tip} [data-testid='payment-tooltip-unpaid']") |> render()
|
||||||
|
assert unpaid =~ "2023"
|
||||||
|
refute unpaid =~ "2024"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows correct color coding for paid status", %{conn: conn} do
|
test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id})
|
||||||
|
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :paid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "a[data-testid='payment-badge']#payment-badge-#{member.id}")
|
||||||
assert html =~ "text-success" || html =~ "hero-check-circle"
|
refute has_element?(view, "#payment-tip-#{member.id}")
|
||||||
|
refute has_element?(view, "a#payment-badge-#{member.id}[phx-hook]")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows correct color coding for unpaid status", %{conn: conn} do
|
test "members without cycles render without error", %{conn: conn} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
member = create_member(%{first_name: "NoCycles", membership_fee_type_id: fee_type.id})
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :unpaid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
assert html =~ "text-error" || html =~ "hero-x-circle"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows correct color coding for suspended status", %{conn: conn} do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :suspended,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles members without cycles gracefully", %{conn: conn} do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
# No cycles created
|
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
html = render(view)
|
|
||||||
# Should not crash, may show empty or default state
|
|
||||||
assert html =~ member.first_name
|
assert html =~ member.first_name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "filters" do
|
describe "payment filter" do
|
||||||
test "filter unpaid in last cycle works", %{conn: conn} do
|
setup do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
||||||
# Member with unpaid last cycle
|
unpaid = create_member(%{first_name: "HasUnpaid", membership_fee_type_id: fee_type.id})
|
||||||
member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
|
create_cycle(unpaid, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||||
|
|
||||||
create_cycle(member1, fee_type, %{
|
paid = create_member(%{first_name: "FullyPaid", membership_fee_type_id: fee_type.id})
|
||||||
cycle_start: ~D[2023-01-01],
|
create_cycle(paid, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||||
status: :unpaid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
# Member with paid last cycle
|
%{}
|
||||||
member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member2, fee_type, %{
|
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :paid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
# Verify cycles exist in database
|
|
||||||
cycles1 =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member1.id)
|
|
||||||
|> Ash.read!(actor: system_actor)
|
|
||||||
|
|
||||||
cycles2 =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member2.id)
|
|
||||||
|> Ash.read!(actor: system_actor)
|
|
||||||
|
|
||||||
refute Enum.empty?(cycles1)
|
|
||||||
refute Enum.empty?(cycles2)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
|
|
||||||
|
|
||||||
assert html =~ "UnpaidMember"
|
|
||||||
refute html =~ "PaidMember"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter unpaid in current cycle works", %{conn: conn} do
|
test "has-unpaid ≥ 1 selects only members with unpaid cycles", %{conn: conn} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
{:ok, _view, html} = live(conn, "/members?pay_filter=unpaid_1")
|
||||||
|
|
||||||
today = Date.utc_today()
|
assert html =~ "HasUnpaid"
|
||||||
current_year_start = %{today | month: 1, day: 1}
|
refute html =~ "FullyPaid"
|
||||||
|
|
||||||
# Member with unpaid current cycle
|
|
||||||
member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member1, fee_type, %{
|
|
||||||
cycle_start: current_year_start,
|
|
||||||
status: :unpaid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
# Member with paid current cycle
|
|
||||||
member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member2, fee_type, %{
|
|
||||||
cycle_start: current_year_start,
|
|
||||||
status: :paid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
# Verify cycles exist in database
|
|
||||||
cycles1 =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member1.id)
|
|
||||||
|> Ash.read!(actor: system_actor)
|
|
||||||
|
|
||||||
cycles2 =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member2.id)
|
|
||||||
|> Ash.read!(actor: system_actor)
|
|
||||||
|
|
||||||
refute Enum.empty?(cycles1)
|
|
||||||
refute Enum.empty?(cycles2)
|
|
||||||
|
|
||||||
{:ok, _view, html} =
|
|
||||||
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
|
||||||
|
|
||||||
assert html =~ "UnpaidCurrent"
|
|
||||||
refute html =~ "PaidCurrent"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@moduletag :slow
|
test "fully-paid selects only members with zero unpaid cycles", %{conn: conn} do
|
||||||
describe "performance" do
|
{:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
|
||||||
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create multiple members with cycles
|
assert html =~ "FullyPaid"
|
||||||
Enum.each(1..5, fn _ ->
|
refute html =~ "HasUnpaid"
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: ~D[2023-01-01],
|
|
||||||
status: :paid,
|
|
||||||
replace_existing: true
|
|
||||||
})
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Should render without errors (N+1 would cause performance issues)
|
|
||||||
assert html =~ "Members" || html =~ "Mitglieder"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentAgingPropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.5 — Unpaid-cycle count correctness (period-scoped, member-relative).
|
||||||
|
|
||||||
|
For arbitrary per-member cycle sets (status × cycle_end) and arbitrary active
|
||||||
|
periods, the `unpaid_cycle_count` calculation equals the set-theoretic
|
||||||
|
definition `|{c : c.status == :unpaid and c.cycle_end ∈ period}|` — paid and
|
||||||
|
suspended cycles excluded, nil period bounds unbounded.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
# Distinct monthly cycle_starts so cycles never collide on the
|
||||||
|
# (member_id, cycle_start) identity; monthly keeps each cycle_end inside a
|
||||||
|
# single month for legible in/out-of-period placement.
|
||||||
|
@starts for y <- 2022..2026, m <- [1, 4, 7, 10], do: Date.new!(y, m, 1)
|
||||||
|
@bounds [nil, ~D[2023-01-01], ~D[2024-06-30], ~D[2025-12-31]]
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
%{actor: actor, fee_type: fee_type}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cycle_gen do
|
||||||
|
StreamData.tuple(
|
||||||
|
{StreamData.member_of(@starts), StreamData.member_of([:unpaid, :paid, :suspended])}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp in_period?(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
|
||||||
|
|
||||||
|
property "unpaid_cycle_count equals the set-theoretic definition", %{
|
||||||
|
actor: actor,
|
||||||
|
fee_type: ft
|
||||||
|
} do
|
||||||
|
check all(
|
||||||
|
cycles <- StreamData.list_of(cycle_gen(), max_length: 6),
|
||||||
|
from <- StreamData.member_of(@bounds),
|
||||||
|
to <- StreamData.member_of(@bounds),
|
||||||
|
max_runs: 40
|
||||||
|
) do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
cycles
|
||||||
|
|> Enum.uniq_by(fn {start, _status} -> start end)
|
||||||
|
|> Enum.each(fn {start, status} ->
|
||||||
|
create_cycle(member, ft, %{cycle_start: start, status: status}, actor)
|
||||||
|
end)
|
||||||
|
|
||||||
|
stored =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
expected =
|
||||||
|
Enum.count(stored, fn c ->
|
||||||
|
c.status == :unpaid and in_period?(c.cycle_end, from, to)
|
||||||
|
end)
|
||||||
|
|
||||||
|
actual =
|
||||||
|
member
|
||||||
|
|> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor)
|
||||||
|
|> Map.fetch!(:unpaid_cycle_count)
|
||||||
|
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
# Clean up so populations do not accumulate across runs.
|
||||||
|
Enum.each(stored, &Ash.destroy!(&1, actor: actor))
|
||||||
|
Ash.destroy!(member, actor: actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
79
test/mv_web/member_live/index_payment_aging_test.exs
Normal file
79
test/mv_web/member_live/index_payment_aging_test.exs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentAgingTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.13 / §3.3 — the period-scoped `unpaid_cycle_count` calculation on Member.
|
||||||
|
|
||||||
|
Counts a member's cycles with `status == :unpaid` whose denormalized
|
||||||
|
`cycle_end` falls inside the active period; paid and suspended cycles are
|
||||||
|
never counted. A nil period bound means "unbounded on that side" (the
|
||||||
|
all-outstanding default counts every unpaid cycle).
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
# Monthly so several cycles can share one period without cycle_start collisions.
|
||||||
|
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
%{actor: actor, fee_type: fee_type}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count(member, from, to, actor) do
|
||||||
|
member
|
||||||
|
|> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor)
|
||||||
|
|> Map.fetch!(:unpaid_cycle_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts only unpaid cycles with cycle_end in the period; suspended and paid excluded",
|
||||||
|
%{actor: actor, fee_type: ft} do
|
||||||
|
# No membership_fee_type_id on the member itself → no auto-generated cycles.
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
# In-period unpaid (counted)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor)
|
||||||
|
# In-period but not unpaid (excluded)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :paid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :suspended}, actor)
|
||||||
|
# Unpaid but out of period (excluded)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
|
||||||
|
|
||||||
|
assert count(member, ~D[2024-01-01], ~D[2024-12-31], actor) == 2
|
||||||
|
|
||||||
|
# Fully-paid member (only paid/suspended in period) → 0
|
||||||
|
paid_member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(paid_member, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
|
||||||
|
create_cycle(paid_member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor)
|
||||||
|
assert count(paid_member, ~D[2024-01-01], ~D[2024-12-31], actor) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil period bounds count every unpaid cycle (all-outstanding default)",
|
||||||
|
%{actor: actor, fee_type: ft} do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-06-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2025-01-01], status: :paid}, actor)
|
||||||
|
|
||||||
|
assert count(member, nil, nil, actor) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "loads for a whole read query (DB-pushable aggregate)", %{actor: actor, fee_type: ft} do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
|
||||||
|
|
||||||
|
[loaded] =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(id == ^member.id)
|
||||||
|
|> Ash.Query.load(
|
||||||
|
unpaid_cycle_count: %{period_from: ~D[2024-01-01], period_to: ~D[2024-12-31]}
|
||||||
|
)
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
assert loaded.unpaid_cycle_count == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
41
test/mv_web/member_live/index_payment_count_codec_test.exs
Normal file
41
test/mv_web/member_live/index_payment_count_codec_test.exs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentCountCodecTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.22 / §3.14 — URL codec for the two-sided payment open-cycle count range.
|
||||||
|
|
||||||
|
The `{:unpaid_range, min, max}` filter round-trips through the flat URL
|
||||||
|
contract: encoding to params then decoding yields the canonical filter, with
|
||||||
|
the "has open" default (`min=1`, `max=nil`) and both-bounded ranges covered.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
|
|
||||||
|
defp roundtrip(filter),
|
||||||
|
do: filter |> PaymentAging.filter_to_params() |> PaymentAging.parse_filter_params()
|
||||||
|
|
||||||
|
test "default has-open range (min 1, unbounded above) round-trips" do
|
||||||
|
assert roundtrip({:unpaid_range, 1, nil}) == {:unpaid_range, 1, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "two-sided range round-trips" do
|
||||||
|
assert roundtrip({:unpaid_range, 2, 5}) == {:unpaid_range, 2, 5}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "exact single-value range round-trips" do
|
||||||
|
assert roundtrip({:unpaid_range, 3, 3}) == {:unpaid_range, 3, 3}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has_unpaid params decode into an unpaid_range" do
|
||||||
|
params = %{"pay_filter" => "has_unpaid", "pay_min" => "2", "pay_max" => "4"}
|
||||||
|
assert PaymentAging.parse_filter_params(params) == {:unpaid_range, 2, 4}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has_unpaid with no bounds defaults to min 1, max nil" do
|
||||||
|
assert PaymentAging.parse_filter_params(%{"pay_filter" => "has_unpaid"}) ==
|
||||||
|
{:unpaid_range, 1, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fully_paid still round-trips" do
|
||||||
|
assert roundtrip(:fully_paid) == :fully_paid
|
||||||
|
end
|
||||||
|
end
|
||||||
95
test/mv_web/member_live/index_payment_filter_test.exs
Normal file
95
test/mv_web/member_live/index_payment_filter_test.exs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.16 / §2.2 / §3.3 — the payment-count filter in OverviewQuery.
|
||||||
|
|
||||||
|
`fully_paid` selects members with 0 unpaid cycles in the active period;
|
||||||
|
`{:has_unpaid, N}` selects members with at least N — consistent with the
|
||||||
|
aging count (§2.5). All filtering resolves DB-side via the
|
||||||
|
`unpaid_cycle_count` calculation.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
@period %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
|
# Wipe the seeded/other members so id sets are deterministic.
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
|
||||||
|
%{actor: actor, ft: ft}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_with_unpaid(n, actor, ft) do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
Enum.each(1..max(n, 0)//1, fn i ->
|
||||||
|
create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor)
|
||||||
|
end)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(filter, actor) do
|
||||||
|
%{payment_filter: filter, payment_period: @period}
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fully_paid selects members with zero in-period unpaid cycles", %{actor: actor, ft: ft} do
|
||||||
|
paid = member_with_unpaid(0, actor, ft)
|
||||||
|
create_cycle(paid, ft, %{cycle_start: ~D[2024-05-01], status: :paid}, actor)
|
||||||
|
one = member_with_unpaid(1, actor, ft)
|
||||||
|
|
||||||
|
result = ids(:fully_paid, actor)
|
||||||
|
assert MapSet.member?(result, paid.id)
|
||||||
|
refute MapSet.member?(result, one.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has-unpaid thresholds select members with count >= N", %{actor: actor, ft: ft} do
|
||||||
|
zero = member_with_unpaid(0, actor, ft)
|
||||||
|
one = member_with_unpaid(1, actor, ft)
|
||||||
|
two = member_with_unpaid(2, actor, ft)
|
||||||
|
three = member_with_unpaid(3, actor, ft)
|
||||||
|
|
||||||
|
assert ids({:has_unpaid, 1}, actor) == MapSet.new([one.id, two.id, three.id])
|
||||||
|
assert ids({:has_unpaid, 2}, actor) == MapSet.new([two.id, three.id])
|
||||||
|
assert ids({:has_unpaid, 3}, actor) == MapSet.new([three.id])
|
||||||
|
refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unpaid-range selects members whose in-period count is within [min, max]", %{
|
||||||
|
actor: actor,
|
||||||
|
ft: ft
|
||||||
|
} do
|
||||||
|
zero = member_with_unpaid(0, actor, ft)
|
||||||
|
one = member_with_unpaid(1, actor, ft)
|
||||||
|
two = member_with_unpaid(2, actor, ft)
|
||||||
|
three = member_with_unpaid(3, actor, ft)
|
||||||
|
|
||||||
|
# two-sided: 2..3
|
||||||
|
assert ids({:unpaid_range, 2, 3}, actor) == MapSet.new([two.id, three.id])
|
||||||
|
# lower-bounded only: >= 1 (max nil)
|
||||||
|
assert ids({:unpaid_range, 1, nil}, actor) == MapSet.new([one.id, two.id, three.id])
|
||||||
|
# exact single value: 1..1
|
||||||
|
assert ids({:unpaid_range, 1, 1}, actor) == MapSet.new([one.id])
|
||||||
|
refute MapSet.member?(ids({:unpaid_range, 1, nil}, actor), zero.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor)
|
||||||
|
|
||||||
|
refute MapSet.member?(ids({:has_unpaid, 1}, actor), member.id)
|
||||||
|
assert MapSet.member?(ids(:fully_paid, actor), member.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
218
test/mv_web/member_live/index_payment_period_test.exs
Normal file
218
test/mv_web/member_live/index_payment_period_test.exs
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.13 / §1.14 / §3.3 — the PaymentAging module: period parse/serialize with
|
||||||
|
the all-outstanding default, badge formatting, and the open-cycles list that
|
||||||
|
backs the badge tooltip (unpaid cycles for the period; suspended shown apart).
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
describe "period parsing / serialization" do
|
||||||
|
test "default period is all-outstanding (both bounds nil)" do
|
||||||
|
assert PaymentAging.default_period() == %{from: nil, to: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no params parses to the default period" do
|
||||||
|
assert PaymentAging.parse_period(%{}) == %{from: nil, to: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses ISO bounds and round-trips through to_params" do
|
||||||
|
period = %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
||||||
|
params = PaymentAging.to_params(period)
|
||||||
|
|
||||||
|
assert params["pay_from"] == "2024-01-01"
|
||||||
|
assert params["pay_to"] == "2024-12-31"
|
||||||
|
assert PaymentAging.parse_period(params) == period
|
||||||
|
end
|
||||||
|
|
||||||
|
test "default period serializes to no params" do
|
||||||
|
assert PaymentAging.to_params(PaymentAging.default_period()) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "malformed bounds fall back to nil" do
|
||||||
|
assert PaymentAging.parse_period(%{"pay_from" => "not-a-date"}) == %{from: nil, to: nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "period short code (§1.28)" do
|
||||||
|
test "the all-outstanding default has no short code" do
|
||||||
|
assert PaymentAging.period_short_code(%{from: nil, to: nil}) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a full calendar year renders as the year" do
|
||||||
|
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-12-31]}) == "2026"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a full calendar quarter renders as Q# YYYY" do
|
||||||
|
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-03-31]}) ==
|
||||||
|
"Q1 2026"
|
||||||
|
|
||||||
|
assert PaymentAging.period_short_code(%{from: ~D[2026-10-01], to: ~D[2026-12-31]}) ==
|
||||||
|
"Q4 2026"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an arbitrary bounded period has no compact short code" do
|
||||||
|
assert PaymentAging.period_short_code(%{from: ~D[2026-02-03], to: ~D[2026-08-17]}) == nil
|
||||||
|
# An open-ended period is not compactly codeable either.
|
||||||
|
assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: nil}) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "period tooltip (§1.28)" do
|
||||||
|
test "names the period with locally formatted bounds" do
|
||||||
|
tip = PaymentAging.period_tooltip(%{from: ~D[2026-01-01], to: ~D[2026-12-31]})
|
||||||
|
assert tip =~ "01.01.2026"
|
||||||
|
assert tip =~ "31.12.2026"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the all-outstanding default reads as all outstanding" do
|
||||||
|
tip = PaymentAging.period_tooltip(%{from: nil, to: nil})
|
||||||
|
assert is_binary(tip) and tip =~ "outstanding"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "explains that the values refer to the filtered contribution period" do
|
||||||
|
# The header badge falls back to a plain "filtered" label whenever no
|
||||||
|
# compact short code fits (arbitrary or open-ended ranges); its tooltip
|
||||||
|
# must spell out what "filtered" means, not just repeat the range.
|
||||||
|
for period <- [
|
||||||
|
%{from: nil, to: nil},
|
||||||
|
%{from: ~D[2026-02-03], to: ~D[2026-08-17]},
|
||||||
|
%{from: ~D[2026-01-01], to: nil}
|
||||||
|
] do
|
||||||
|
tip = PaymentAging.period_tooltip(period)
|
||||||
|
assert tip =~ "refer to the filtered contribution period"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "payment-count filter codec" do
|
||||||
|
test "round-trips fully_paid and has-unpaid thresholds" do
|
||||||
|
for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do
|
||||||
|
params = PaymentAging.filter_to_params(state)
|
||||||
|
assert PaymentAging.parse_filter_params(params) == state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil filter serializes to no params and unknown values parse to nil" do
|
||||||
|
assert PaymentAging.filter_to_params(nil) == %{}
|
||||||
|
assert PaymentAging.parse_filter_params(%{}) == nil
|
||||||
|
assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "serializes to stable string tokens" do
|
||||||
|
assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"}
|
||||||
|
assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "payment URL round-trip (§2.1, payment keys)" do
|
||||||
|
@dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]]
|
||||||
|
@filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}]
|
||||||
|
|
||||||
|
property "decode∘encode is canonical and idempotent for period + payment filter" do
|
||||||
|
check all(
|
||||||
|
from <- StreamData.member_of(@dates),
|
||||||
|
to <- StreamData.member_of(@dates),
|
||||||
|
filter <- StreamData.member_of(@filters)
|
||||||
|
) do
|
||||||
|
period = %{from: from, to: to}
|
||||||
|
|
||||||
|
params =
|
||||||
|
period
|
||||||
|
|> PaymentAging.to_params()
|
||||||
|
|> Map.merge(PaymentAging.filter_to_params(filter))
|
||||||
|
|
||||||
|
assert PaymentAging.parse_period(params) == period
|
||||||
|
assert PaymentAging.parse_filter_params(params) == filter
|
||||||
|
|
||||||
|
# Idempotence: re-encoding the decoded state yields the same params.
|
||||||
|
reencoded =
|
||||||
|
params
|
||||||
|
|> PaymentAging.parse_period()
|
||||||
|
|> PaymentAging.to_params()
|
||||||
|
|> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params)))
|
||||||
|
|
||||||
|
assert reencoded == params
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "badge/1" do
|
||||||
|
test "0 renders as All paid (success/green), color derived from the shared status variant" do
|
||||||
|
badge = PaymentAging.badge(0)
|
||||||
|
refute Map.has_key?(badge, :variant)
|
||||||
|
assert badge.color_class == "badge-success"
|
||||||
|
assert badge.label == "All paid"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "N > 0 renders as N open (error/red), color derived from the shared status variant" do
|
||||||
|
badge = PaymentAging.badge(2)
|
||||||
|
refute Map.has_key?(badge, :variant)
|
||||||
|
assert badge.color_class == "badge-error"
|
||||||
|
assert badge.label =~ "2"
|
||||||
|
assert badge.label =~ "open"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "open_cycles/2" do
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
%{actor: actor, ft: ft}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lists in-period unpaid and suspended cycles chronologically; paid/out-of-period excluded",
|
||||||
|
%{actor: actor, ft: ft} do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :suspended}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :paid}, actor)
|
||||||
|
# out of period
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
|
||||||
|
|
||||||
|
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
|
||||||
|
result = PaymentAging.open_cycles(member, %{from: ~D[2024-01-01], to: ~D[2024-12-31]})
|
||||||
|
|
||||||
|
assert Enum.map(result, & &1.status) == [:unpaid, :unpaid, :suspended]
|
||||||
|
|
||||||
|
assert Enum.map(result, & &1.cycle_start) == [
|
||||||
|
~D[2024-01-01],
|
||||||
|
~D[2024-02-01],
|
||||||
|
~D[2024-03-01]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil bounds include every unpaid/suspended cycle", %{actor: actor, ft: ft} do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :suspended}, actor)
|
||||||
|
|
||||||
|
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
|
||||||
|
result = PaymentAging.open_cycles(member, PaymentAging.default_period())
|
||||||
|
|
||||||
|
assert Enum.map(result, & &1.status) == [:unpaid, :suspended]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tooltip_cycles/1 (five-slot cap)" do
|
||||||
|
test "five or fewer cycles show in full with no overflow" do
|
||||||
|
cycles = for i <- 1..5, do: %{n: i}
|
||||||
|
assert PaymentAging.tooltip_cycles(cycles) == {cycles, 0}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "more than five collapses to first four cycles plus the overflow count" do
|
||||||
|
cycles = for i <- 1..19, do: %{n: i}
|
||||||
|
{shown, overflow} = PaymentAging.tooltip_cycles(cycles)
|
||||||
|
|
||||||
|
assert shown == Enum.take(cycles, 4)
|
||||||
|
assert overflow == 15
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
85
test/mv_web/member_live/index_payment_sort_test.exs
Normal file
85
test/mv_web/member_live/index_payment_sort_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentSortTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.27 — The Beiträge (payment) column sorts DB-side by the period-scoped
|
||||||
|
unpaid-cycle count, ascending and descending, with the unique id tie-breaker
|
||||||
|
preserving keyset stability. Verified at the `OverviewQuery` layer.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
@period %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
%{actor: actor, ft: ft}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_with_unpaid(n, ctx) do
|
||||||
|
member = member_fixture_with_actor(%{}, ctx.actor)
|
||||||
|
|
||||||
|
Enum.each(1..max(n, 0)//1, fn i ->
|
||||||
|
create_cycle(
|
||||||
|
member,
|
||||||
|
ctx.ft,
|
||||||
|
%{cycle_start: Date.new!(2024, i, 1), status: :unpaid},
|
||||||
|
ctx.actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sorted_ids(order, ctx) do
|
||||||
|
%{sort_field: :payment, sort_order: order, payment_period: @period}
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.read!(actor: ctx.actor)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sorts ascending by in-period unpaid count", ctx do
|
||||||
|
zero = member_with_unpaid(0, ctx)
|
||||||
|
one = member_with_unpaid(1, ctx)
|
||||||
|
two = member_with_unpaid(2, ctx)
|
||||||
|
|
||||||
|
assert sorted_ids(:asc, ctx) == [zero.id, one.id, two.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sorts descending by in-period unpaid count", ctx do
|
||||||
|
zero = member_with_unpaid(0, ctx)
|
||||||
|
one = member_with_unpaid(1, ctx)
|
||||||
|
two = member_with_unpaid(2, ctx)
|
||||||
|
|
||||||
|
assert sorted_ids(:desc, ctx) == [two.id, one.id, zero.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "payment sort header (LiveView)" do
|
||||||
|
setup %{conn: conn} do
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :ui
|
||||||
|
test "clicking the payment header reflects the sort in the URL", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("[data-testid='payment']") |> render_click()
|
||||||
|
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "sort_field=payment"
|
||||||
|
assert path =~ "sort_order=asc"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :ui
|
||||||
|
test "the payment th exposes aria-sort when active", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members?sort_field=payment&sort_order=desc")
|
||||||
|
|
||||||
|
assert has_element?(view, "th[aria-sort='descending']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
94
test/mv_web/member_live/index_payment_wiring_test.exs
Normal file
94
test/mv_web/member_live/index_payment_wiring_test.exs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.16 / §1.17 — the member overview LiveView honours the period-scoped
|
||||||
|
payment-count filter and payment-period URL contract (`pay_filter`,
|
||||||
|
`pay_from`, `pay_to`), driving the same DB result set the OverviewQuery unit
|
||||||
|
tests pin, but through `handle_params` and the query wiring.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft = create_fee_type(%{interval: :monthly}, actor)
|
||||||
|
|
||||||
|
# Deterministic id set: drop the seeded members.
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), actor: actor, ft: ft}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_with_unpaid(n, actor, ft) do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
Enum.each(1..max(n, 0)//1, fn i ->
|
||||||
|
create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor)
|
||||||
|
end)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has-unpaid >= 2 shows only members with at least two in-period unpaid cycles", ctx do
|
||||||
|
one = member_with_unpaid(1, ctx.actor, ctx.ft)
|
||||||
|
two = member_with_unpaid(2, ctx.actor, ctx.ft)
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(ctx.conn, "/members?pay_filter=unpaid_2&pay_from=2024-01-01&pay_to=2024-12-31")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{two.id}")
|
||||||
|
refute has_element?(view, "#row-#{one.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fully_paid shows only members with zero in-period unpaid cycles", ctx do
|
||||||
|
paid = member_with_unpaid(0, ctx.actor, ctx.ft)
|
||||||
|
create_cycle(paid, ctx.ft, %{cycle_start: ~D[2024-05-01], status: :paid}, ctx.actor)
|
||||||
|
unpaid = member_with_unpaid(1, ctx.actor, ctx.ft)
|
||||||
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(ctx.conn, "/members?pay_filter=fully_paid&pay_from=2024-01-01&pay_to=2024-12-31")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{paid.id}")
|
||||||
|
refute has_element?(view, "#row-#{unpaid.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the active period scopes the filter: a cycle outside the period does not count", ctx do
|
||||||
|
# One unpaid cycle in 2023, none in 2024. With a 2024 period, has-unpaid>=1
|
||||||
|
# must not select this member; with the all-outstanding default it must.
|
||||||
|
member = member_fixture_with_actor(%{}, ctx.actor)
|
||||||
|
create_cycle(member, ctx.ft, %{cycle_start: ~D[2023-03-01], status: :unpaid}, ctx.actor)
|
||||||
|
|
||||||
|
{:ok, scoped, _} =
|
||||||
|
live(ctx.conn, "/members?pay_filter=unpaid_1&pay_from=2024-01-01&pay_to=2024-12-31")
|
||||||
|
|
||||||
|
refute has_element?(scoped, "#row-#{member.id}")
|
||||||
|
|
||||||
|
{:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1")
|
||||||
|
assert has_element?(all_time, "#row-#{member.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "period indicator badge (§1.28)" do
|
||||||
|
test "no badge in the default all-outstanding scope", ctx do
|
||||||
|
{:ok, view, _} = live(ctx.conn, "/members")
|
||||||
|
refute has_element?(view, "[data-testid='payment-period-badge']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a full year renders the compact year short code", ctx do
|
||||||
|
{:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-01-01&pay_to=2026-12-31")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='payment-period-badge']", "2026")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an arbitrary period falls back to a 'filtered' badge with a period tooltip", ctx do
|
||||||
|
{:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-02-03&pay_to=2026-08-17")
|
||||||
|
|
||||||
|
badge = view |> element("[data-testid='payment-period-badge']") |> render()
|
||||||
|
assert badge =~ "03.02.2026"
|
||||||
|
assert badge =~ "17.08.2026"
|
||||||
|
assert badge =~ "refer to the filtered contribution period"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
40
test/mv_web/member_live/index_status_params_codec_test.exs
Normal file
40
test/mv_web/member_live/index_status_params_codec_test.exs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.1 (extended) — URL codec round-trip for the new v2 filter keys: the
|
||||||
|
point-in-time as-of date and the suspended-status flag. Encoding a state to
|
||||||
|
params then decoding yields the canonical state, and the default (no filter)
|
||||||
|
encodes to the empty map so a fresh URL is canonical.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.AsOfDate
|
||||||
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
|
|
||||||
|
describe "as-of date" do
|
||||||
|
test "a set date round-trips" do
|
||||||
|
date = ~D[2026-03-15]
|
||||||
|
assert date |> AsOfDate.to_params() |> AsOfDate.parse() == date
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nil encodes to the empty map and decodes to nil" do
|
||||||
|
assert AsOfDate.to_params(nil) == %{}
|
||||||
|
assert AsOfDate.parse(%{}) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a malformed date param decodes to nil" do
|
||||||
|
assert AsOfDate.parse(%{"as_of_date" => "not-a-date"}) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "suspended flag" do
|
||||||
|
test "true round-trips" do
|
||||||
|
assert true |> PaymentAging.suspended_to_params() |> PaymentAging.parse_suspended() == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "false/nil encodes to the empty map and decodes to false" do
|
||||||
|
assert PaymentAging.suspended_to_params(false) == %{}
|
||||||
|
assert PaymentAging.suspended_to_params(nil) == %{}
|
||||||
|
assert PaymentAging.parse_suspended(%{}) == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
58
test/mv_web/member_live/index_suspended_filter_test.exs
Normal file
58
test/mv_web/member_live/index_suspended_filter_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexSuspendedFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.23 — Filter by `suspended` status.
|
||||||
|
|
||||||
|
With the suspended filter active, the overview keeps members that have at
|
||||||
|
least one `suspended` cycle whose `cycle_end` falls in the active period.
|
||||||
|
Suspended is filterable as its own status, independent of the unpaid count
|
||||||
|
(which excludes suspended, §2.5). Verified at the `OverviewQuery` layer.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2, create_fee_type: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
ft = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
%{actor: actor, ft: ft}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_with_cycle(ctx, status, cycle_start) do
|
||||||
|
m = member_fixture_with_actor(%{}, ctx.actor)
|
||||||
|
create_cycle(m, ctx.ft, %{cycle_start: cycle_start, status: status}, ctx.actor)
|
||||||
|
m
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(opts, actor) do
|
||||||
|
opts |> OverviewQuery.build() |> Ash.read!(actor: actor) |> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps members with a suspended cycle whose cycle_end is in the period", ctx do
|
||||||
|
suspended_in = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
|
||||||
|
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
|
||||||
|
_paid = member_with_cycle(ctx, :paid, ~D[2025-01-01])
|
||||||
|
_suspended_out = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
|
||||||
|
|
||||||
|
result =
|
||||||
|
ids(
|
||||||
|
%{suspended: true, payment_period: %{from: ~D[2025-01-01], to: ~D[2025-12-31]}},
|
||||||
|
ctx.actor
|
||||||
|
)
|
||||||
|
|
||||||
|
assert MapSet.equal?(result, MapSet.new([suspended_in.id]))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "all-time period keeps every member with any suspended cycle", ctx do
|
||||||
|
s1 = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
|
||||||
|
s2 = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
|
||||||
|
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
|
||||||
|
|
||||||
|
result = ids(%{suspended: true, payment_period: %{from: nil, to: nil}}, ctx.actor)
|
||||||
|
|
||||||
|
assert MapSet.equal?(result, MapSet.new([s1.id, s2.id]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -473,17 +473,26 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Opens the member-filter dropdown so its boolean filter controls are rendered.
|
# Number of active add-filter chips currently rendered. The builder shows one
|
||||||
defp open_member_filter(view) do
|
# chip per active filter value; this replaces the removed panel's active-count
|
||||||
view
|
# badge as the observable carrier of the filter count.
|
||||||
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|
defp chip_count(view) do
|
||||||
|> render_click()
|
render(view)
|
||||||
|
|> LazyHTML.from_fragment()
|
||||||
|
|> LazyHTML.query(~s([data-testid="filter-chip"]))
|
||||||
|
|> Enum.count()
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns the rendered HTML of the member-filter dropdown (with it open).
|
# Whether a boolean custom-field filter chip for `field` in the given state
|
||||||
defp member_filter_html(view) do
|
# ("Yes"/"No") is rendered.
|
||||||
open_member_filter(view)
|
defp boolean_chip?(view, field, state) do
|
||||||
render(view)
|
render(view) =~ "#{field.name}: #{state}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether the field picker offers a pickable option for the given custom field.
|
||||||
|
defp picker_has_field?(view, field_id) do
|
||||||
|
open_picker(view)
|
||||||
|
has_element?(view, "#member-filter-field-opt-#{field_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "copy_emails feature" do
|
describe "copy_emails feature" do
|
||||||
|
|
@ -797,10 +806,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
assert html =~ "hero-chevron-down"
|
assert html =~ "hero-chevron-down"
|
||||||
|
|
||||||
# The bulk-actions trigger and the bespoke member-filter trigger each
|
# The bulk-actions trigger carries its own trailing chevron.
|
||||||
# carry their own chevron; assert the filter trigger's chevron is pinned
|
assert has_element?(view, "[data-testid='bulk-actions-button'] .hero-chevron-down")
|
||||||
# independently, so removing it from the filter component fails this test.
|
|
||||||
assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dropdown opens and closes on click", %{conn: conn} do
|
test "dropdown opens and closes on click", %{conn: conn} do
|
||||||
|
|
@ -1055,7 +1062,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
|
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
|
{:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
|
||||||
|
|
||||||
badge = scope_badge(html)
|
badge = scope_badge(html)
|
||||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||||
|
|
@ -1162,230 +1169,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "cycle status filter" do
|
|
||||||
# Helper to create a member (only used in this describe block)
|
|
||||||
defp create_member(attrs, actor) do
|
|
||||||
default_attrs = %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs = Map.merge(default_attrs, attrs)
|
|
||||||
{:ok, member} = Membership.create_member(attrs, actor: actor)
|
|
||||||
member
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
|
||||||
today = Date.utc_today()
|
|
||||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
|
||||||
|
|
||||||
# Member with paid last cycle
|
|
||||||
paid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "PaidLast",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
paid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Member with unpaid last cycle
|
|
||||||
unpaid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "UnpaidLast",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
unpaid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
|
|
||||||
|
|
||||||
assert html =~ "PaidLast"
|
|
||||||
refute html =~ "UnpaidLast"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
|
||||||
today = Date.utc_today()
|
|
||||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
|
||||||
|
|
||||||
# Member with paid last cycle
|
|
||||||
paid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "PaidLast",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
paid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: last_year_start, status: :paid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Member with unpaid last cycle
|
|
||||||
unpaid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "UnpaidLast",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
unpaid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
|
|
||||||
|
|
||||||
refute html =~ "PaidLast"
|
|
||||||
assert html =~ "UnpaidLast"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filter shows only members with paid status in current cycle", %{conn: conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
|
||||||
today = Date.utc_today()
|
|
||||||
current_year_start = Date.new!(today.year, 1, 1)
|
|
||||||
|
|
||||||
# Member with paid current cycle
|
|
||||||
paid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "PaidCurrent",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
paid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: current_year_start, status: :paid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Member with unpaid current cycle
|
|
||||||
unpaid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "UnpaidCurrent",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
unpaid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
|
|
||||||
|
|
||||||
assert html =~ "PaidCurrent"
|
|
||||||
refute html =~ "UnpaidCurrent"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
|
||||||
today = Date.utc_today()
|
|
||||||
current_year_start = Date.new!(today.year, 1, 1)
|
|
||||||
|
|
||||||
# Member with paid current cycle
|
|
||||||
paid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "PaidCurrent",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
paid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: current_year_start, status: :paid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Member with unpaid current cycle
|
|
||||||
unpaid_member =
|
|
||||||
create_member(
|
|
||||||
%{
|
|
||||||
first_name: "UnpaidCurrent",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
create_cycle(
|
|
||||||
unpaid_member,
|
|
||||||
fee_type,
|
|
||||||
%{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
|
|
||||||
system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, _view, html} =
|
|
||||||
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
|
||||||
|
|
||||||
refute html =~ "PaidCurrent"
|
|
||||||
assert html =~ "UnpaidCurrent"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
# Start with last cycle view and paid filter
|
|
||||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
|
|
||||||
|
|
||||||
# Toggle to current cycle - this should update URL and preserve filter
|
|
||||||
# Use the button in the toolbar
|
|
||||||
view
|
|
||||||
|> element("button[phx-click='toggle_cycle_view']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Wait for patch to complete
|
|
||||||
path = assert_patch(view)
|
|
||||||
|
|
||||||
# URL should contain both filter and show_current_cycle
|
|
||||||
assert path =~ "cycle_status_filter=paid"
|
|
||||||
assert path =~ "show_current_cycle=true"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "boolean custom field filters" do
|
describe "boolean custom field filters" do
|
||||||
# Helper to create a boolean custom field (uses system actor for authorization)
|
# Helper to create a boolean custom field (uses system actor for authorization)
|
||||||
defp create_boolean_custom_field(attrs \\ %{}) do
|
defp create_boolean_custom_field(attrs \\ %{}) do
|
||||||
|
|
@ -1454,12 +1237,13 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
string_field = create_string_custom_field(%{name: "Phone Number"})
|
string_field = create_string_custom_field(%{name: "Phone Number"})
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
open_member_filter(view)
|
open_picker(view)
|
||||||
|
|
||||||
# Only the boolean fields render a tri-state filter control; the string field does not.
|
# Only the boolean fields are offered as pickable filter fields; the string
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
|
# field is not filterable on the overview.
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
|
assert has_element?(view, "#member-filter-field-opt-#{boolean_field1.id}")
|
||||||
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
|
assert has_element?(view, "#member-filter-field-opt-#{boolean_field2.id}")
|
||||||
|
refute has_element?(view, "#member-filter-field-opt-#{string_field.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
|
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
|
||||||
|
|
@ -1471,19 +1255,16 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
field_m = create_boolean_custom_field(%{name: "Middle Field"})
|
field_m = create_boolean_custom_field(%{name: "Middle Field"})
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
html = member_filter_html(view)
|
open_picker(view)
|
||||||
|
|
||||||
# The rendered boolean filter controls appear in name-ascending order.
|
# The picker lists the boolean custom fields in name-ascending order.
|
||||||
rendered_order =
|
rendered_order =
|
||||||
html
|
render(view)
|
||||||
|> LazyHTML.from_fragment()
|
|> LazyHTML.from_fragment()
|
||||||
|> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"]))
|
|> LazyHTML.query(~s(#member-filter-field-listbox [role="option"]))
|
||||||
|> LazyHTML.attribute("id")
|
|> LazyHTML.attribute("id")
|
||||||
|> Enum.map(
|
|> Enum.map(&String.replace_prefix(&1, "member-filter-field-opt-", ""))
|
||||||
&(&1
|
|> Enum.filter(&(&1 in [field_a.id, field_m.id, field_z.id]))
|
||||||
|> String.replace_prefix("custom-boolean-filter-", "")
|
|
||||||
|> String.replace_suffix("-all", ""))
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rendered_order == [field_a.id, field_m.id, field_z.id]
|
assert rendered_order == [field_a.id, field_m.id, field_z.id]
|
||||||
end
|
end
|
||||||
|
|
@ -1492,17 +1273,14 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
# Test true value: the "Yes" radio is checked (the boolean true, not the string "true").
|
# Test true value: a "Yes" chip for the field (the boolean true, not "true").
|
||||||
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
|
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||||
open_member_filter(view1)
|
assert boolean_chip?(view1, boolean_field, "Yes")
|
||||||
assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
refute boolean_chip?(view1, boolean_field, "No")
|
||||||
refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
|
||||||
|
|
||||||
# Test false value: the "No" radio is checked.
|
# Test false value: a "No" chip for the field.
|
||||||
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
|
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
|
||||||
open_member_filter(view2)
|
assert boolean_chip?(view2, boolean_field, "No")
|
||||||
assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
|
|
||||||
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
|
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
|
||||||
|
|
@ -1510,11 +1288,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
fake_id = Ecto.UUID.generate()
|
fake_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
|
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
|
||||||
open_member_filter(view)
|
|
||||||
|
|
||||||
# No filter control exists for a non-existent field, and no active-filter badge appears.
|
# A non-existent field is not offered in the picker and produces no chip.
|
||||||
refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
|
refute picker_has_field?(view, fake_id)
|
||||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
assert chip_count(view) == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
|
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
|
||||||
|
|
@ -1522,11 +1299,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
string_field = create_string_custom_field()
|
string_field = create_string_custom_field()
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
|
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
|
||||||
open_member_filter(view)
|
|
||||||
|
|
||||||
# A string field is never rendered as a boolean filter, and no filter becomes active.
|
# A string field is never a filterable field, and no filter becomes active.
|
||||||
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
|
refute picker_has_field?(view, string_field.id)
|
||||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
assert chip_count(view) == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params ignores invalid filter values", %{conn: conn} do
|
test "handle_params ignores invalid filter values", %{conn: conn} do
|
||||||
|
|
@ -1538,11 +1314,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
for invalid_value <- invalid_values do
|
for invalid_value <- invalid_values do
|
||||||
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
|
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
|
||||||
open_member_filter(view)
|
|
||||||
|
|
||||||
# An invalid value leaves the field's filter at "All" (no filter applied).
|
# An invalid value applies no filter, so no chip is rendered.
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
|
assert chip_count(view) == 0,
|
||||||
"Invalid value '#{invalid_value}' should leave the filter at All"
|
"Invalid value '#{invalid_value}' should leave the filter unset"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1557,16 +1332,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
|
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
|
||||||
)
|
)
|
||||||
|
|
||||||
open_member_filter(view)
|
# Both filters are reflected as chips: field1 at "Yes", field2 at "No", for
|
||||||
|
# a total of two active-filter chips.
|
||||||
# Both filters are reflected: field1 at "Yes", field2 at "No", and the
|
assert boolean_chip?(view, boolean_field1, "Yes")
|
||||||
# active-filter count badge shows 2.
|
assert boolean_chip?(view, boolean_field2, "No")
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]")
|
assert chip_count(view) == 2
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]")
|
|
||||||
|
|
||||||
assert view
|
|
||||||
|> element(~s(button[aria-label="Filter members"] .badge), "2")
|
|
||||||
|> has_element?()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "build_query_params includes active boolean filters and excludes nil filters", %{
|
test "build_query_params includes active boolean filters and excludes nil filters", %{
|
||||||
|
|
@ -1630,22 +1400,20 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
assert path2 =~ "bf_#{boolean_field.id}=true"
|
assert path2 =~ "bf_#{boolean_field.id}=true"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filters work together with cycle_status_filter", %{conn: conn} do
|
test "boolean filters work together with the payment filter", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(
|
live(
|
||||||
conn,
|
conn,
|
||||||
"/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
|
"/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true"
|
||||||
)
|
)
|
||||||
|
|
||||||
open_member_filter(view)
|
# Both filters are reflected as chips: the boolean field at "Yes" and the
|
||||||
|
# period-scoped payment filter as "All paid".
|
||||||
# Both filters are reflected in the rendered controls: the boolean field at
|
assert boolean_chip?(view, boolean_field, "Yes")
|
||||||
# "Yes" and the payment-status filter at "paid".
|
assert render(view) =~ "All paid"
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
|
||||||
assert has_element?(view, "#payment-filter-paid[checked]")
|
|
||||||
|
|
||||||
# Both should be in URL when triggering search
|
# Both should be in URL when triggering search
|
||||||
view
|
view
|
||||||
|
|
@ -1653,7 +1421,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> render_change(%{value: "test"})
|
|> render_change(%{value: "test"})
|
||||||
|
|
||||||
path = assert_patch(view)
|
path = assert_patch(view)
|
||||||
assert path =~ "cycle_status_filter=paid"
|
assert path =~ "pay_filter=fully_paid"
|
||||||
assert path =~ "bf_#{boolean_field.id}=true"
|
assert path =~ "bf_#{boolean_field.id}=true"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1666,8 +1434,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
open_member_filter(view)
|
assert boolean_chip?(view, boolean_field, "Yes")
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
|
||||||
|
|
||||||
# Delete the custom field (requires actor with destroy permission)
|
# Delete the custom field (requires actor with destroy permission)
|
||||||
Ash.destroy!(boolean_field, actor: system_actor)
|
Ash.destroy!(boolean_field, actor: system_actor)
|
||||||
|
|
@ -1676,11 +1443,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view2, _html} =
|
{:ok, view2, _html} =
|
||||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
open_member_filter(view2)
|
# The deleted field is not offered in the picker and no filter is active.
|
||||||
|
refute picker_has_field?(view2, boolean_field.id)
|
||||||
# The deleted field renders no filter control and no filter is active.
|
assert chip_count(view2) == 0
|
||||||
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}")
|
|
||||||
refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
|
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
|
||||||
|
|
@ -1693,11 +1458,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?bf_#{encoded_id}=true")
|
live(conn, "/members?bf_#{encoded_id}=true")
|
||||||
|
|
||||||
open_member_filter(view)
|
|
||||||
|
|
||||||
# Phoenix decodes the param, so the filter applies under the original ID:
|
# Phoenix decodes the param, so the filter applies under the original ID:
|
||||||
# the "Yes" radio for the field is checked.
|
# a "Yes" chip is rendered for the field.
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
assert boolean_chip?(view, boolean_field, "Yes")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
|
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
|
||||||
|
|
@ -1708,12 +1471,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
|
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
open_member_filter(view)
|
# The double-prefixed param is not a valid filter: no chip is active.
|
||||||
|
assert chip_count(view) == 0
|
||||||
# The double-prefixed param is not a valid filter: the field stays at "All"
|
|
||||||
# and no filter is active.
|
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
|
||||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
|
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
|
||||||
|
|
@ -1728,11 +1487,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
|
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
|
||||||
|
|
||||||
# The active-filter count badge is the observable carrier of the filter count.
|
# The active-filter chips are the observable carrier of the filter count.
|
||||||
# With 60 requested filters, the DoS cap clamps it to exactly 50.
|
# With 60 requested filters, the DoS cap clamps it to exactly 50.
|
||||||
assert view
|
assert chip_count(view) == 50
|
||||||
|> element(~s(button[aria-label="Filter members"] .badge), "50")
|
|
||||||
|> has_element?()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
|
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
|
||||||
|
|
@ -1745,12 +1502,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?bf_#{fake_long_id}=true")
|
live(conn, "/members?bf_#{fake_long_id}=true")
|
||||||
|
|
||||||
open_member_filter(view)
|
# The over-long ID is rejected: no filter is active. (`boolean_field` is
|
||||||
|
# created only to prove a real field is unaffected.)
|
||||||
# The over-long ID is rejected: the real field stays at "All" and no filter
|
assert boolean_field.id
|
||||||
# is active.
|
assert chip_count(view) == 0
|
||||||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
|
||||||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member with a boolean custom field value
|
# Helper to create a member with a boolean custom field value
|
||||||
|
|
@ -2092,7 +1847,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
refute html_false =~ "NoValue"
|
refute html_false =~ "NoValue"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
|
test "boolean filter integration works together with the payment filter", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
@ -2156,11 +1911,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
system_actor
|
system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test both filters together
|
# Test both filters together: fully-paid (0 unpaid cycles in the period)
|
||||||
|
# plus the boolean field at "Yes".
|
||||||
{:ok, _view, html} =
|
{:ok, _view, html} =
|
||||||
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
|
live(conn, "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
# Only member_paid_true should match both filters
|
# Only member_paid_true has zero unpaid cycles and the boolean value true.
|
||||||
assert html =~ "PaidTrue"
|
assert html =~ "PaidTrue"
|
||||||
refute html =~ "UnpaidTrue"
|
refute html =~ "UnpaidTrue"
|
||||||
end
|
end
|
||||||
|
|
@ -2246,25 +2002,25 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
|
test "boolean custom field appears in the field picker after being added", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Start with no boolean custom fields
|
# Start with no boolean custom fields
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
open_member_filter(view)
|
open_picker(view)
|
||||||
|
|
||||||
# No boolean field control is rendered yet.
|
# With no custom fields, the picker renders no "Custom fields" group.
|
||||||
refute has_element?(view, ~s(input[name^="custom_boolean"]))
|
refute has_element?(view, "[data-testid='field-group-header']", "Custom fields")
|
||||||
|
|
||||||
# Create a new boolean custom field
|
# Create a new boolean custom field
|
||||||
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
|
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
|
||||||
|
|
||||||
# Navigate again - the new field should appear in the filter dropdown.
|
# Navigate again - the new field should appear in the picker.
|
||||||
{:ok, view2, _html} = live(conn, "/members")
|
{:ok, view2, _html} = live(conn, "/members")
|
||||||
html_after = member_filter_html(view2)
|
open_picker(view2)
|
||||||
|
|
||||||
assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
|
assert has_element?(view2, "#member-filter-field-opt-#{new_boolean_field.id}")
|
||||||
assert html_after =~ "Newly Added Field"
|
assert render(view2) =~ "Newly Added Field"
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :slow
|
@tag :slow
|
||||||
|
|
|
||||||
257
test/mv_web/member_live/index_value_control_test.exs
Normal file
257
test/mv_web/member_live/index_value_control_test.exs
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexValueControlTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.20 / §1.21 / §1.22 / §1.23 — the reworked value controls of the add-filter
|
||||||
|
builder:
|
||||||
|
|
||||||
|
* no "All"/"Alle" option in the boolean / group value controls (§1.20);
|
||||||
|
* the field picker's search input is a live type-ahead (§1.21);
|
||||||
|
* the payment control reveals a two-sided open-cycle count range under
|
||||||
|
"Has unpaid" and offers a suspended-status option (§1.22, §1.23).
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
alias Mv.Membership.Group
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Val", last_name: "Member", email: "val@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group} =
|
||||||
|
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "newsletter_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: :boolean,
|
||||||
|
show_in_overview: true
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), group: group, field: field}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unified popover shell (§3.11)" do
|
||||||
|
test "every value control shares the 'Filter: <field>' shell title", %{
|
||||||
|
conn: conn,
|
||||||
|
field: field
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "group")
|
||||||
|
assert has_element?(view, "[data-testid='control-title']", "Filter:")
|
||||||
|
|
||||||
|
pick_field(view, field.id)
|
||||||
|
assert has_element?(view, "[data-testid='control-title']", "Filter:")
|
||||||
|
|
||||||
|
pick_field(view, "payment")
|
||||||
|
assert has_element?(view, "[data-testid='control-title']", "Filter:")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "no All option (§1.20)" do
|
||||||
|
test "the group control offers is / is-not but no 'all' option", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "group")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group'] input[value='in']")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group'] input[value='not_in']")
|
||||||
|
refute has_element?(view, "[data-testid='value-control-group'] input[value='all']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the group operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "group")
|
||||||
|
|
||||||
|
# Same join-toggle presentation as the boolean custom-field control: a
|
||||||
|
# check icon on the ":in" (Ja) option and an x-mark on ":not_in" (Nein),
|
||||||
|
# with a visible selected state.
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group'] .join .hero-check-circle")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-group'] .join .hero-x-circle")
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='value-control-group'] label.has-\\[\\:checked\\]\\:btn-primary"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the fee-type operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
|
||||||
|
_fee_type = Mv.Fixtures.create_fee_type()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "fee_type")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-check-circle")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-x-circle")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the boolean control offers Yes / No but no 'all' option", %{conn: conn, field: field} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, field.id)
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='value-control-boolean'] input[value='true']")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-boolean'] input[value='false']")
|
||||||
|
refute has_element?(view, "[data-testid='value-control-boolean'] input[value='all']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the boolean control renders a check-icon Yes and an x-icon No join toggle", %{
|
||||||
|
conn: conn,
|
||||||
|
field: field
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, field.id)
|
||||||
|
|
||||||
|
# Join-toggle with a check icon on Yes and an x-mark icon on No (Task 5).
|
||||||
|
assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-check-circle")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-x-circle")
|
||||||
|
# A checked option shows a visible selected state (btn-primary via has-[:checked]).
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='value-control-boolean'] label.has-\\[\\:checked\\]\\:btn-primary"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "field picker search (§1.21)" do
|
||||||
|
test "the search input is a live type-ahead hook and narrows the field list", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_picker(view)
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='field-combobox'][phx-hook='FilterComboboxSearch']")
|
||||||
|
# The search input must live inside a form carrying the change binding —
|
||||||
|
# LiveView rejects `phx-change` on an input that is not inside a <form>
|
||||||
|
# ("form events require the input to be inside a form"), which silently
|
||||||
|
# broke the live filtering in the browser (§1.21).
|
||||||
|
assert has_element?(view, "form[phx-change='filter_fields'] [data-testid='field-combobox']")
|
||||||
|
|
||||||
|
# A non-matching query removes the Group option from the listbox.
|
||||||
|
view
|
||||||
|
|> form("[data-testid='field-search-form']", %{"picker_query" => "zzzznomatch"})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
refute has_element?(view, "#member-filter-field-opt-group")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "payment status labels" do
|
||||||
|
test "the toggle reads 'All paid' / 'Open contributions'", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "payment")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='value-control-payment']", "All paid")
|
||||||
|
assert has_element?(view, "[data-testid='value-control-payment']", "Open contributions")
|
||||||
|
refute has_element?(view, "[data-testid='value-control-payment']", "Fully paid")
|
||||||
|
refute has_element?(view, "[data-testid='value-control-payment']", "Has unpaid")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the applied fully-paid chip reads 'All paid'", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, "/members?pay_filter=fully_paid")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='filter-chip']", "All paid")
|
||||||
|
refute render(view) =~ "Fully paid"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "payment control (§1.22, §1.23)" do
|
||||||
|
test "'Has unpaid' reveals the two-sided open-cycle count range", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "payment")
|
||||||
|
refute has_element?(view, "[data-testid='payment-count-range']")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-payment']")
|
||||||
|
|> render_change(%{
|
||||||
|
"__payment" => "1",
|
||||||
|
"pay" => "has_unpaid",
|
||||||
|
"pay_min" => "1",
|
||||||
|
"pay_max" => ""
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = render(view)
|
||||||
|
assert_patch(view)
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_min']")
|
||||||
|
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_max']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the suspended option activates the suspended-status filter in the URL", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
pick_field(view, "payment")
|
||||||
|
assert has_element?(view, "[data-testid='payment-suspended'] input[name='suspended']")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-payment']")
|
||||||
|
|> render_change(%{"__payment" => "1", "suspended" => "1"})
|
||||||
|
|
||||||
|
_ = render(view)
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "suspended=1"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unified date-range controls" do
|
||||||
|
# Every date-range value control (payment period, join date, exit date, and
|
||||||
|
# custom date fields) renders the same shared native Von/Bis row: a
|
||||||
|
# non-wrapping flex row whose two `<input type="date">` are each wrapped in
|
||||||
|
# `min-w-0 flex-1` so they share width equally and stay on one line.
|
||||||
|
test "payment period, join, exit and custom date all render the shared row", %{conn: conn} do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, date_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "birthday_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: :date,
|
||||||
|
show_in_overview: true
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert_shared_range(view, "payment", "payment-period-range", "pay_from", "pay_to")
|
||||||
|
assert_shared_range(view, "join_date", "custom-range", "jd_from", "jd_to")
|
||||||
|
assert_shared_range(view, "active_former", "exit-custom-range", "ed_from", "ed_to")
|
||||||
|
|
||||||
|
cd_from = "cdf_#{date_field.id}_from"
|
||||||
|
cd_to = "cdf_#{date_field.id}_to"
|
||||||
|
assert_shared_range(view, date_field.id, "custom-range", cd_from, cd_to)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Opens the given field's value control and asserts its date-range row is the
|
||||||
|
# shared native Von/Bis layout: a `flex` (non-wrapping) row whose two date
|
||||||
|
# inputs are each wrapped in `min-w-0 flex-1` (equal width, single line).
|
||||||
|
defp assert_shared_range(view, field, testid, from_name, to_name) do
|
||||||
|
pick_field(view, field)
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='#{testid}'].flex")
|
||||||
|
refute has_element?(view, "[data-testid='#{testid}'].flex-wrap")
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{from_name}']"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{to_name}']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -36,6 +36,7 @@ defmodule MvWeb.ConnCase do
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
import Phoenix.ConnTest
|
import Phoenix.ConnTest
|
||||||
import MvWeb.ConnCase
|
import MvWeb.ConnCase
|
||||||
|
import MvWeb.FilterBuilderHelpers
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
90
test/support/filter_builder_helpers.ex
Normal file
90
test/support/filter_builder_helpers.ex
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
defmodule MvWeb.FilterBuilderHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Test helpers for driving the member-overview `AddFilterBuilderComponent`.
|
||||||
|
|
||||||
|
The add-filter flow is: open the picker → pick a field → change the focused
|
||||||
|
value control (which commits the filter and re-loads the list). These helpers
|
||||||
|
encapsulate that sequence so tests read at the intent level and stay robust to
|
||||||
|
the builder's internal markup.
|
||||||
|
|
||||||
|
Each `apply_*` returns the patched path (the new `/members?…` URL) so callers
|
||||||
|
can assert the URL-encoded filter contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
@builder_id "member-filter"
|
||||||
|
|
||||||
|
@doc "Opens the field picker."
|
||||||
|
def open_picker(view) do
|
||||||
|
view |> element("[data-testid='add-filter-trigger']") |> render_click()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Opens the picker and selects the field with the given descriptor key."
|
||||||
|
def pick_field(view, key) do
|
||||||
|
open_picker(view)
|
||||||
|
view |> element("##{@builder_id}-field-opt-#{key}") |> render_click()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Applies a group in/not_in/all filter through the builder."
|
||||||
|
def apply_group_filter(view, group_id, value) do
|
||||||
|
pick_field(view, "group")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-group']")
|
||||||
|
|> render_change(%{"group_#{group_id}" => to_string(value)})
|
||||||
|
|
||||||
|
flush_and_patch(view)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Applies a fee-type in/not_in/all filter through the builder."
|
||||||
|
def apply_fee_type_filter(view, fee_type_id, value) do
|
||||||
|
pick_field(view, "fee_type")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-fee-type']")
|
||||||
|
|> render_change(%{"fee_type_#{fee_type_id}" => to_string(value)})
|
||||||
|
|
||||||
|
flush_and_patch(view)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Applies a boolean custom-field filter (true/false/all) through the builder."
|
||||||
|
def apply_boolean_filter(view, field_id, value) do
|
||||||
|
pick_field(view, field_id)
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-boolean']")
|
||||||
|
|> render_change(%{"custom_boolean" => %{"#{field_id}" => to_string(value)}})
|
||||||
|
|
||||||
|
flush_and_patch(view)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Applies a payment status filter (and optionally a period) through the builder.
|
||||||
|
`status` is `"fully_paid"` or `"has_unpaid"`; `from`/`to` are ISO-8601 strings
|
||||||
|
or `""`. For `"has_unpaid"` the open-cycle range defaults to `min` 1 / no max.
|
||||||
|
"""
|
||||||
|
def apply_payment_filter(view, status, from \\ "", to \\ "", min \\ "1", max \\ "") do
|
||||||
|
pick_field(view, "payment")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='value-control-payment']")
|
||||||
|
|> render_change(%{
|
||||||
|
"__payment" => "1",
|
||||||
|
"pay" => status,
|
||||||
|
"pay_min" => min,
|
||||||
|
"pay_max" => max,
|
||||||
|
"pay_from" => from,
|
||||||
|
"pay_to" => to
|
||||||
|
})
|
||||||
|
|
||||||
|
flush_and_patch(view)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Forces the LiveView to process the queued filter message(s) and returns the
|
||||||
|
# resulting patched path.
|
||||||
|
defp flush_and_patch(view) do
|
||||||
|
_ = render(view)
|
||||||
|
assert_patch(view)
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue