Compare commits
25 commits
main
...
issue/mitg
| Author | SHA1 | Date | |
|---|---|---|---|
| fc634813d8 | |||
| 4d6294771a | |||
| e967cb59e9 | |||
| 0acd12360f | |||
| ce877463d6 | |||
| 8303a39041 | |||
| c0d1550401 | |||
| c547ca19af | |||
| 7d1a71b1fd | |||
| 95ef3177ac | |||
| 4b647c21e7 | |||
| cdb8b03ce1 | |||
| c9dd900a5f | |||
| 4adb6be5e5 | |||
| 0cc37f7e9b | |||
| 1c67527c56 | |||
| 77fc11a0b0 | |||
| c2cb3edab8 | |||
| 0c375234c8 | |||
| dfc616257d | |||
| af2cc2e0d4 | |||
| b79d7ac9ea | |||
| b09cdf7f3a | |||
| e64f55c36a | |||
| b745b13ca5 |
111 changed files with 12316 additions and 4445 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Members overview – view settings** – A new "View" dropdown switches the table between compact and comfortable density and controls how the Member and Address fields are shown: as combined composite cells or split into their underlying columns (Vorname/Nachname/E-Mail resp. Straße/PLZ/Ort). Every choice is remembered per browser.
|
||||||
|
- **Members overview – reset columns to default** – The column-visibility menu gained a reset button that restores the curated default column set.
|
||||||
|
- **Members overview – add-filter builder** – A searchable, grouped field picker adds filters as editable chips instead of a fixed panel; the active filters are encoded in the URL, so a filtered view is bookmarkable and survives a reload.
|
||||||
|
- **Members overview – more filters** – New filters for members active on a chosen date (point-in-time), members with suspended cycles in the period, matching any of several selected groups (OR), and a range on the number of open payment cycles.
|
||||||
|
- **Members overview – payment aging column** – The fees column shows how many billing cycles each member has left unpaid in the selected period (cycles are member-relative), is sortable by that count, carries a period indicator on its header, and lists the open — and separately, the suspended — cycles in a hover tooltip.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Members overview – loads on demand** – The overview no longer reads every member into memory. Filtering, sorting and pagination now run in the database, and rows stream in via infinite scroll as you reach the bottom of the list, so large member lists open quickly. The exact number of matching members is announced (for example "250 Members").
|
||||||
|
- **Members overview – condensed name and address** – By default a member shows as a composite name cell and a two-line address cell (street / postal code + city); both columns are sortable and city and postal code stay findable via search. A clear (×) button was added to the search field.
|
||||||
|
- **Members overview – bulk actions span the full result set** – Select-all, copy e-mail addresses, and export now act on the complete filtered set rather than only the rows currently loaded, and the count reflects the full filtered total.
|
||||||
|
- **Members overview – sticky header, pinned column and sortable headers** – The table keeps a sticky header and pinned identifier column, sort headers that expose the active sort direction, boolean columns rendered as ✓/✗ icons, larger touch targets in both densities, and result-count and loading changes announced through a polite live region — meeting WCAG 2.2 AA for accessibility.
|
||||||
|
- **Members overview – filter panel replaced** – The vertical filter panel is replaced by the add-filter builder, and date ranges now use native date inputs with relative presets, with a quick year jump and manual entry.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Members overview – keyboard-operable filter toggles** – The filter builder's Yes/No and status toggles can now be reached and switched with the keyboard and show a visible focus indicator (WCAG 2.1.1 Keyboard, 2.4.7 Focus Visible).
|
||||||
|
|
||||||
## [1.3.0] - 2026-06-16
|
## [1.3.0] - 2026-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -678,6 +678,78 @@
|
||||||
|
|
||||||
/* This file is for your main application CSS */
|
/* This file is for your main application CSS */
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Table header density scaling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DaisyUI table size modifiers (table-xs, table-sm, etc.) scope their font-size
|
||||||
|
* rule to ":not(thead, tfoot) tr", so the header row stays at the base 0.875 rem
|
||||||
|
* regardless of the chosen density. We extend each modifier to thead so the
|
||||||
|
* header scales together with the body.
|
||||||
|
*/
|
||||||
|
.table.table-xs thead tr { font-size: 0.6875rem; }
|
||||||
|
.table.table-sm thead tr { font-size: 0.75rem; }
|
||||||
|
.table.table-lg thead tr { font-size: 1.125rem; }
|
||||||
|
.table.table-xl thead tr { font-size: 1.375rem; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Density-tracking badges and sort icons in the member overview.
|
||||||
|
*
|
||||||
|
* The member table renders its rows via LiveView streams, so toggling density
|
||||||
|
* only swaps the <table> size class (table-xs <-> table-md); it does NOT
|
||||||
|
* re-render the streamed rows. A per-row `size=` conditional therefore bakes a
|
||||||
|
* stale badge size into the streamed DOM. To make badges (and, for visual
|
||||||
|
* consistency, the header sort glyphs) follow the density toggle without any
|
||||||
|
* row re-render, their size is driven purely from the table size class here.
|
||||||
|
*
|
||||||
|
* Badge values mirror daisyUI's own .badge-xs / .badge-md (font-size, --size
|
||||||
|
* which the base .badge uses for height, and padding-inline). Scoped to the
|
||||||
|
* member overview via #members-keyboard so unrelated badges are unaffected.
|
||||||
|
*/
|
||||||
|
#members-keyboard .table.table-xs .badge {
|
||||||
|
--size: calc(var(--size-selector, 0.25rem) * 4);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding-inline: calc(0.25rem * 2 - var(--border));
|
||||||
|
}
|
||||||
|
#members-keyboard .table.table-md .badge {
|
||||||
|
--size: calc(var(--size-selector, 0.25rem) * 6);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding-inline: calc(0.25rem * 3 - var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort glyphs scale with the header text (11px compact / 14px comfortable). */
|
||||||
|
#members-keyboard .table.table-md thead .sort-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
#members-keyboard .table.table-xs thead .sort-icon {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Infinite-scroll loading row.
|
||||||
|
*
|
||||||
|
* The loading indicator must read like a full, accented data row rather than a
|
||||||
|
* narrow pinned bar. The accent (base-200 background + a top separator) and the
|
||||||
|
* padding live on the full-width footer cell, which spans every column via
|
||||||
|
* colspan, so the accent is visible edge-to-edge even while the table is scrolled
|
||||||
|
* horizontally. The spinner + label inside stay pinned to the visible viewport
|
||||||
|
* width (StickyViewportWidth hook) and transparent, so they remain centered on
|
||||||
|
* horizontal scroll while the cell accent shows through behind them.
|
||||||
|
*
|
||||||
|
* The row height tracks the active density (table-xs compact / table-md
|
||||||
|
* comfortable) so it matches a normal data row in each mode.
|
||||||
|
*/
|
||||||
|
#members-footer > tr > td {
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
border-top: 1px solid var(--color-base-300);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.table.table-xs #members-loading-bar { min-height: 3.375rem; }
|
||||||
|
.table.table-md #members-loading-bar { min-height: 4.1875rem; }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SortableList: drag-and-drop table rows
|
SortableList: drag-and-drop table rows
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
@ -773,3 +845,130 @@
|
||||||
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
|
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sticky checkbox column: border-collapse: separate avoids the Chromium artifact
|
||||||
|
* where a collapsed row border is repainted (a spurious 1px line) under a
|
||||||
|
* position: sticky cell. The row separators are moved onto the cells so they
|
||||||
|
* still render in the separate model (matching DaisyUI's faint divider color).
|
||||||
|
*/
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"]
|
||||||
|
.table.table-zebra
|
||||||
|
:where(thead tr, tbody tr:not(:last-child))
|
||||||
|
> :where(th, td) {
|
||||||
|
border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Horizontal-scroll fade for the pinned checkbox column: instead of hiding the
|
||||||
|
* scrolled-away content behind a hard opaque block, a gradient on the right edge
|
||||||
|
* of the sticky cell fades the incoming content, signalling there is more behind
|
||||||
|
* it. overflow: visible lets the ::after gradient extend past the cell; the cell
|
||||||
|
* keeps its own (zebra) background so the checkbox stays legible.
|
||||||
|
*/
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell::after,
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 100%;
|
||||||
|
width: 1.25rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"]
|
||||||
|
.table.table-zebra
|
||||||
|
tbody
|
||||||
|
tr:nth-child(odd)
|
||||||
|
> td.sticky-first-col-cell::after {
|
||||||
|
background: linear-gradient(to right, var(--color-base-100), transparent);
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"]
|
||||||
|
.table.table-zebra
|
||||||
|
tbody
|
||||||
|
tr:nth-child(even)
|
||||||
|
> td.sticky-first-col-cell::after {
|
||||||
|
background: linear-gradient(to right, var(--color-base-200), transparent);
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"]
|
||||||
|
.table.table-zebra
|
||||||
|
tbody
|
||||||
|
tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible))
|
||||||
|
> td.sticky-first-col-cell::after {
|
||||||
|
background: linear-gradient(to right, var(--color-base-300), transparent);
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after {
|
||||||
|
background: linear-gradient(to right, var(--color-base-100), transparent);
|
||||||
|
}
|
||||||
|
/* Compact density: narrower fade so the gradient does not visually reach into the
|
||||||
|
checkbox column's own content area in the tighter table-xs cell. */
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs td.sticky-first-col-cell::after,
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs thead th.sticky-first-col-th::after {
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sort-header tooltip. Rendered via the native Popover API (see the SortTooltip
|
||||||
|
* JS hook), so it lives in the browser TOP LAYER: it is not clipped by the
|
||||||
|
* members table's overflow (overflow-x:auto forces overflow-y:auto, which used
|
||||||
|
* to clip a CSS pseudo-tooltip below the header) and paints above the sticky
|
||||||
|
* header, neighbor cells, and the pinned checkbox column's fade — without any
|
||||||
|
* z-index hacks. This block only resets the popover UA defaults and gives it a
|
||||||
|
* small daisyUI-flavoured tooltip look; the JS hook sets top/left each show.
|
||||||
|
*/
|
||||||
|
.sort-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 16rem;
|
||||||
|
overflow: visible;
|
||||||
|
background: var(--color-neutral, oklch(0.2 0 0));
|
||||||
|
color: var(--color-neutral-content, oklch(0.98 0 0));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Payment-badge tooltip. A native Popover-API element (opened by the
|
||||||
|
* HoverPopover hook), so it lives in the browser TOP LAYER and is not clipped by
|
||||||
|
* the members table's overflow. Placement is pure CSS anchor positioning: the
|
||||||
|
* badge carries `anchor-name`, this popover carries `position-anchor`, and
|
||||||
|
* `position-area` drops it just below the badge, left-aligned. A `position-try`
|
||||||
|
* fallback flips it above the badge when there is no room below. No JS
|
||||||
|
* coordinate math is involved (unlike the older SortTooltip).
|
||||||
|
*/
|
||||||
|
.payment-tip {
|
||||||
|
margin: 0;
|
||||||
|
position-area: bottom span-right;
|
||||||
|
position-try-fallbacks: flip-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vertically center the row checkbox in the sticky first column using the
|
||||||
|
* table-native vertical-align: middle. This keeps display: table-cell intact so
|
||||||
|
* the cell background fills the full row height (no white gap at the bottom).
|
||||||
|
* The fieldset margin-bottom reset removes the mb-2 from the shared input
|
||||||
|
* component so no residual margin shifts the checkbox off-center.
|
||||||
|
*/
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell,
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell fieldset,
|
||||||
|
[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th fieldset {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
176
assets/js/app.js
176
assets/js/app.js
|
|
@ -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,27 @@ 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
|
||||||
|
// 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
|
||||||
|
// (e.g. select-all reflecting a partial selection — WCAG select-all semantics).
|
||||||
|
Hooks.IndeterminateCheckbox = {
|
||||||
|
updateIndeterminate() {
|
||||||
|
this.el.indeterminate = this.el.dataset.indeterminate === "true"
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateIndeterminate()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.updateIndeterminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
||||||
Hooks.CopyToClipboard = {
|
Hooks.CopyToClipboard = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -126,6 +149,29 @@ Hooks.RowSelectionGuard = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width
|
||||||
|
// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar
|
||||||
|
// lives in a full-width table row that is wider than the viewport when the table scrolls
|
||||||
|
// horizontally; matching the sticky element to the container's visible width keeps its
|
||||||
|
// centered content pinned at the bottom-center of the visible area instead of drifting off
|
||||||
|
// with the scrolled table. The data-scroll-container attribute names the container element id.
|
||||||
|
Hooks.StickyViewportWidth = {
|
||||||
|
mounted() {
|
||||||
|
this.container = document.getElementById(this.el.dataset.scrollContainer)
|
||||||
|
this.sync = () => {
|
||||||
|
if (this.container) this.el.style.width = this.container.clientWidth + "px"
|
||||||
|
}
|
||||||
|
this.sync()
|
||||||
|
window.addEventListener("resize", this.sync)
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.sync()
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
window.removeEventListener("resize", this.sync)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
||||||
Hooks.FocusRestore = {
|
Hooks.FocusRestore = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -313,6 +359,72 @@ Hooks.SortableList = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SortTooltip hook: shows the sort-header hint in the browser TOP LAYER via the
|
||||||
|
// native Popover API, so it escapes the members table's overflow clipping
|
||||||
|
// (overflow-x:auto forces overflow-y:auto, which clips a CSS tooltip below the
|
||||||
|
// header) and paints above the sticky header, neighbor cells, and the pinned
|
||||||
|
// checkbox column's fade — no z-index fight. The popover is shown on hover AND
|
||||||
|
// keyboard focus and positioned next to the header with getBoundingClientRect
|
||||||
|
// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox).
|
||||||
|
Hooks.SortTooltip = {
|
||||||
|
mounted() {
|
||||||
|
this.tip = document.getElementById(this.el.dataset.tooltipId)
|
||||||
|
// Feature-detect: without Popover API support fall back to no visual tooltip
|
||||||
|
// (the button's aria-label still conveys the hint to assistive tech).
|
||||||
|
if (!this.tip || typeof this.tip.showPopover !== "function") return
|
||||||
|
|
||||||
|
this.position = () => {
|
||||||
|
const btn = this.el.getBoundingClientRect()
|
||||||
|
const tip = this.tip.getBoundingClientRect()
|
||||||
|
const gap = 6
|
||||||
|
// Prefer just below the header; flip above when there is no room below.
|
||||||
|
let top = btn.bottom + gap
|
||||||
|
if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) {
|
||||||
|
top = btn.top - gap - tip.height
|
||||||
|
}
|
||||||
|
// Left-align to the header, but keep the whole bubble on-screen.
|
||||||
|
let left = btn.left
|
||||||
|
const maxLeft = window.innerWidth - tip.width - gap
|
||||||
|
if (left > maxLeft) left = maxLeft
|
||||||
|
if (left < gap) left = gap
|
||||||
|
this.tip.style.top = `${Math.round(top)}px`
|
||||||
|
this.tip.style.left = `${Math.round(left)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.show = () => {
|
||||||
|
try {
|
||||||
|
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
|
||||||
|
// showPopover() makes it measurable; positioning in the same tick avoids
|
||||||
|
// a visible flash (JS runs before the browser paints).
|
||||||
|
this.position()
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hide = () => {
|
||||||
|
try {
|
||||||
|
if (this.tip.matches(":popover-open")) this.tip.hidePopover()
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onKey = (e) => { if (e.key === "Escape") this.hide() }
|
||||||
|
|
||||||
|
this.el.addEventListener("mouseenter", this.show)
|
||||||
|
this.el.addEventListener("focus", this.show)
|
||||||
|
this.el.addEventListener("mouseleave", this.hide)
|
||||||
|
this.el.addEventListener("blur", this.hide)
|
||||||
|
this.el.addEventListener("keydown", this.onKey)
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
if (this.hide) this.hide()
|
||||||
|
this.el.removeEventListener("mouseenter", this.show)
|
||||||
|
this.el.removeEventListener("focus", this.show)
|
||||||
|
this.el.removeEventListener("mouseleave", this.hide)
|
||||||
|
this.el.removeEventListener("blur", this.hide)
|
||||||
|
this.el.removeEventListener("keydown", this.onKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||||
Hooks.SidebarState = {
|
Hooks.SidebarState = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
@ -360,15 +472,77 @@ Hooks.SidebarState = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadMorePrefetch: fires an infinite-scroll load event as the sentinel
|
||||||
|
// approaches the viewport, rather than only once it is fully scrolled into view.
|
||||||
|
// An IntersectionObserver rooted at the scroll container with a positive bottom
|
||||||
|
// rootMargin treats the sentinel as visible while it is still that many pixels
|
||||||
|
// below the fold, so the next page loads ahead of time. It complements the
|
||||||
|
// built-in phx-viewport-bottom binding (which stays as a reliable backstop);
|
||||||
|
// duplicate loads are harmless because the server guards on "more?" and the
|
||||||
|
// stream de-duplicates rows by id.
|
||||||
|
Hooks.LoadMorePrefetch = {
|
||||||
|
mounted() {
|
||||||
|
const container = document.getElementById(this.el.dataset.scrollContainer)
|
||||||
|
const rootMargin = `0px 0px ${this.el.dataset.margin || "600px"} 0px`
|
||||||
|
const event = this.el.dataset.event || "load_more"
|
||||||
|
let lastPush = 0
|
||||||
|
|
||||||
|
this.observer = new IntersectionObserver((entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastPush < 400) return
|
||||||
|
lastPush = now
|
||||||
|
this.pushEvent(event)
|
||||||
|
}, {root: container || null, rootMargin, threshold: 0})
|
||||||
|
|
||||||
|
this.observer.observe(this.el)
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
if (this.observer) this.observer.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a browser cookie value by name (used to echo the persisted member
|
||||||
|
// view-settings to the server on connect, since the live socket's connect-info
|
||||||
|
// does not expose cookies).
|
||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"))
|
||||||
|
return match ? decodeURIComponent(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {
|
params: {
|
||||||
_csrf_token: csrfToken,
|
_csrf_token: csrfToken,
|
||||||
timezone: getBrowserTimezone()
|
timezone: getBrowserTimezone(),
|
||||||
|
view_settings: getCookie("member_view_settings")
|
||||||
},
|
},
|
||||||
hooks: Hooks
|
hooks: Hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Persist the member-overview view settings per browser/device (§1.5/§1.20). The
|
||||||
|
// LiveView pushes "store-view-settings" (density + compact field toggles) on
|
||||||
|
// change; we write a long-lived cookie that the LiveView reads back on the next
|
||||||
|
// full page load (via the request cookie on the dead render and via connect
|
||||||
|
// params on the connected mount).
|
||||||
|
// Return the members list to the top after a sort or filter change. The server
|
||||||
|
// resets the keyset stream to page 1 on those changes and pushes this event; a
|
||||||
|
// user scrolled down would otherwise be stranded past the shorter content with
|
||||||
|
// infinite scroll not re-arming. No-op if the scroll container is absent.
|
||||||
|
window.addEventListener("phx:members:scroll-top", () => {
|
||||||
|
const el = document.getElementById("members-table-guard")
|
||||||
|
if (el) el.scrollTop = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener("phx:store-view-settings", (e) => {
|
||||||
|
const json = e.detail && e.detail.view_settings
|
||||||
|
if (typeof json === "string" && json.length > 0) {
|
||||||
|
const maxAge = 365 * 24 * 60 * 60
|
||||||
|
document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Listen for custom events from LiveView
|
// Listen for custom events from LiveView
|
||||||
window.addEventListener("phx:set-input-value", (e) => {
|
window.addEventListener("phx:set-input-value", (e) => {
|
||||||
const {id, value} = e.detail
|
const {id, value} = e.detail
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -48,6 +48,13 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
table "custom_field_values"
|
table "custom_field_values"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
||||||
|
custom_indexes do
|
||||||
|
# GIN index on the JSONB value to serve boolean custom-field containment
|
||||||
|
# predicates (`value @> '{"value": <bool>}'`) used by the member overview.
|
||||||
|
# Default jsonb_ops supports the `@>` operator we rely on.
|
||||||
|
index ["value"], name: "custom_field_values_value_gin_index", using: "gin"
|
||||||
|
end
|
||||||
|
|
||||||
references do
|
references do
|
||||||
reference :member, on_delete: :delete
|
reference :member, on_delete: :delete
|
||||||
reference :custom_field, on_delete: :delete
|
reference :custom_field, on_delete: :delete
|
||||||
|
|
@ -133,6 +140,16 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
|
|
||||||
calculations do
|
calculations do
|
||||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||||
|
|
||||||
|
# Typed sort keys for DB-side ordering of the member overview by a custom
|
||||||
|
# field. The stored JSONB value (`{"type": ..., "value": ...}`) is cast per
|
||||||
|
# the field's value type so integers sort numerically and dates
|
||||||
|
# chronologically. Empty strings collapse to NULL so they sort last, matching
|
||||||
|
# the previous in-memory `CustomFieldSort` behaviour.
|
||||||
|
calculate :sort_text, :string, expr(fragment("nullif(?->>'value','')", value))
|
||||||
|
calculate :sort_numeric, :decimal, expr(fragment("nullif(?->>'value','')::numeric", value))
|
||||||
|
calculate :sort_date, :date, expr(fragment("nullif(?->>'value','')::date", value))
|
||||||
|
calculate :sort_boolean, :boolean, expr(fragment("nullif(?->>'value','')::boolean", value))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ensure a member can only have one custom field value per custom field
|
# Ensure a member can only have one custom field value per custom field
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,18 @@ defmodule Mv.Membership.Member do
|
||||||
accept [:vereinfacht_contact_id]
|
accept [:vereinfacht_contact_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Keyset-paginated read for the member overview. Filters and sort are
|
||||||
|
# composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this
|
||||||
|
# action only enables keyset pagination with a unique `id` tie-breaker so
|
||||||
|
# pages never skip or duplicate rows.
|
||||||
|
read :overview do
|
||||||
|
# Distinct from the plain `:read` (`list_members`), which returns the full
|
||||||
|
# unpaginated set: `:overview` (`overview_members`) is keyset-paginated and
|
||||||
|
# left intentionally bare so `OverviewQuery` composes filters/sort onto it.
|
||||||
|
description "Keyset-paginated, externally-composed member overview read"
|
||||||
|
pagination keyset?: true, default_limit: 50, required?: false
|
||||||
|
end
|
||||||
|
|
||||||
# Action to handle fuzzy search on specific fields
|
# Action to handle fuzzy search on specific fields
|
||||||
read :search do
|
read :search do
|
||||||
argument :query, :string, allow_nil?: true
|
argument :query, :string, allow_nil?: true
|
||||||
|
|
@ -279,27 +291,7 @@ defmodule Mv.Membership.Member do
|
||||||
threshold =
|
threshold =
|
||||||
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
||||||
|
|
||||||
if is_binary(q) and String.trim(q) != "" do
|
apply_fuzzy_search_filter(query, q, threshold)
|
||||||
q2 = String.trim(q)
|
|
||||||
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
|
|
||||||
q2_sanitized = sanitize_search_query(q2)
|
|
||||||
pat = "%" <> q2_sanitized <> "%"
|
|
||||||
|
|
||||||
# Build search filters grouped by search type for maintainability
|
|
||||||
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
|
|
||||||
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
|
|
||||||
fts_match = build_fts_filter(q2)
|
|
||||||
substring_match = build_substring_filter(q2_sanitized, pat)
|
|
||||||
custom_field_match = build_custom_field_filter(pat)
|
|
||||||
fuzzy_match = build_fuzzy_filter(q2, threshold)
|
|
||||||
|
|
||||||
query
|
|
||||||
|> Ash.Query.filter(
|
|
||||||
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -725,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
|
||||||
|
|
@ -738,6 +749,13 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
aggregates do
|
||||||
|
# Alphabetically-first group name, used to sort the overview by group DB-side.
|
||||||
|
# Mirrors the previous in-memory "first group name" sort key; members with no
|
||||||
|
# groups yield nil (sorted last ascending via NULLS LAST).
|
||||||
|
min :first_group_name, :groups, :name
|
||||||
|
end
|
||||||
|
|
||||||
# Define identities for upsert operations
|
# Define identities for upsert operations
|
||||||
identities do
|
identities do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
|
|
@ -1212,6 +1230,43 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Applies the same fuzzy/full-text search filter as the `:search` action as a
|
||||||
|
plain filter on the given query, without switching the query's read action.
|
||||||
|
|
||||||
|
Used by the overview query builder so search composes with the `:overview`
|
||||||
|
keyset-paginated read action instead of replacing it.
|
||||||
|
"""
|
||||||
|
@spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
|
||||||
|
def apply_overview_search(query, q) do
|
||||||
|
apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared by the `:search` action and `apply_overview_search/2`.
|
||||||
|
defp apply_fuzzy_search_filter(query, q, threshold) do
|
||||||
|
if is_binary(q) and String.trim(q) != "" do
|
||||||
|
q2 = String.trim(q)
|
||||||
|
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
|
||||||
|
q2_sanitized = sanitize_search_query(q2)
|
||||||
|
pat = "%" <> q2_sanitized <> "%"
|
||||||
|
|
||||||
|
# Build search filters grouped by search type for maintainability
|
||||||
|
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
|
||||||
|
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
|
||||||
|
fts_match = build_fts_filter(q2)
|
||||||
|
substring_match = build_substring_filter(q2_sanitized, pat)
|
||||||
|
custom_field_match = build_custom_field_filter(pat)
|
||||||
|
fuzzy_match = build_fuzzy_filter(q2, threshold)
|
||||||
|
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Search Input Sanitization
|
# Search Input Sanitization
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ defmodule Mv.Membership do
|
||||||
resources do
|
resources do
|
||||||
resource Mv.Membership.Member do
|
resource Mv.Membership.Member do
|
||||||
define :create_member, action: :create_member
|
define :create_member, action: :create_member
|
||||||
|
# `list_members` returns the full unpaginated set; `overview_members` is the
|
||||||
|
# keyset-paginated base for the member overview (composed by OverviewQuery).
|
||||||
define :list_members, action: :read
|
define :list_members, action: :read
|
||||||
|
define :overview_members, action: :overview
|
||||||
define :update_member, action: :update_member
|
define :update_member, action: :update_member
|
||||||
define :destroy_member, action: :destroy
|
define :destroy_member, action: :destroy
|
||||||
end
|
end
|
||||||
|
|
|
||||||
53
lib/membership_fees/cycle_end_backfill.ex
Normal file
53
lib/membership_fees/cycle_end_backfill.ex
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
defmodule Mv.MembershipFees.CycleEndBackfill do
|
||||||
|
@moduledoc """
|
||||||
|
One-time backfill for the denormalized `cycle_end` column on
|
||||||
|
`membership_fee_cycles`.
|
||||||
|
|
||||||
|
For every cycle whose `cycle_end` is still `NULL`, sets it to
|
||||||
|
`CalendarCycles.calculate_cycle_end(cycle_start, interval)` using the cycle's
|
||||||
|
own fee-type interval. Idempotent: rows that already have a `cycle_end` are
|
||||||
|
left untouched, so it is safe to re-run.
|
||||||
|
|
||||||
|
Invoked from the `cycle_end` migration. Kept as a module function (rather than
|
||||||
|
inline migration SQL) so the exact calendar arithmetic is reused from
|
||||||
|
`CalendarCycles` and can be tested directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
alias Mv.Repo
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Backfills `cycle_end` for all cycles missing it. Returns `{:ok, updated_count}`.
|
||||||
|
"""
|
||||||
|
@spec run() :: {:ok, non_neg_integer()}
|
||||||
|
def run do
|
||||||
|
rows =
|
||||||
|
from(c in "membership_fee_cycles",
|
||||||
|
join: t in "membership_fee_types",
|
||||||
|
on: c.membership_fee_type_id == t.id,
|
||||||
|
where: is_nil(c.cycle_end),
|
||||||
|
select: {c.id, c.cycle_start, t.interval}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
count =
|
||||||
|
Enum.reduce(rows, 0, fn {id, cycle_start, interval}, acc ->
|
||||||
|
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, normalize_interval(interval))
|
||||||
|
|
||||||
|
from(c in "membership_fee_cycles", where: c.id == ^id)
|
||||||
|
|> Repo.update_all(set: [cycle_end: cycle_end])
|
||||||
|
|
||||||
|
acc + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, count}
|
||||||
|
end
|
||||||
|
|
||||||
|
# The raw query returns the enum column as a string.
|
||||||
|
defp normalize_interval(interval) when is_atom(interval), do: interval
|
||||||
|
|
||||||
|
defp normalize_interval(interval) when is_binary(interval),
|
||||||
|
do: String.to_existing_atom(interval)
|
||||||
|
end
|
||||||
|
|
@ -13,7 +13,9 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
- `notes` - Optional notes for this cycle
|
- `notes` - Optional notes for this cycle
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
|
- **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type).
|
||||||
|
Because the fee type interval and the cycle_start are immutable after create,
|
||||||
|
cycle_end never drifts, so it can be filtered/sorted in SQL.
|
||||||
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
|
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
|
||||||
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
|
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
|
||||||
|
|
||||||
|
|
@ -34,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
|
||||||
|
|
@ -46,6 +58,14 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
create :create do
|
create :create do
|
||||||
primary? true
|
primary? true
|
||||||
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
|
accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id]
|
||||||
|
|
||||||
|
argument :interval, :atom do
|
||||||
|
allow_nil? true
|
||||||
|
|
||||||
|
description "Optional pre-resolved fee-type interval; when given, SetCycleEnd skips the fee-type lookup"
|
||||||
|
end
|
||||||
|
|
||||||
|
change Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update do
|
update :update do
|
||||||
|
|
@ -106,6 +126,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
description "Start date of the billing cycle"
|
description "Start date of the billing cycle"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attribute :cycle_end, :date do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
|
||||||
|
description "Denormalized end date of the cycle (cycle_start + interval), set at create"
|
||||||
|
end
|
||||||
|
|
||||||
attribute :amount, :decimal do
|
attribute :amount, :decimal do
|
||||||
allow_nil? false
|
allow_nil? false
|
||||||
public? true
|
public? true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
defmodule Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd do
|
||||||
|
@moduledoc """
|
||||||
|
Ash change that denormalizes `cycle_end` on a membership fee cycle at create time.
|
||||||
|
|
||||||
|
`cycle_end` is computed as `CalendarCycles.calculate_cycle_end(cycle_start, interval)`,
|
||||||
|
where `interval` is read from the cycle's `membership_fee_type`. Because a fee type's
|
||||||
|
`interval` is immutable and the cycle's `cycle_start` is never updated, the stored
|
||||||
|
`cycle_end` is fixed at create time and never drifts.
|
||||||
|
|
||||||
|
When the caller already knows the interval (e.g. the bulk cycle generator, which
|
||||||
|
resolves the fee type once for a whole batch), it may pass it via the `:interval`
|
||||||
|
create argument; the change then uses that value and issues no fee-type query,
|
||||||
|
avoiding an N+1 lookup across a batch of cycles.
|
||||||
|
|
||||||
|
Otherwise the fee-type interval is read with `authorize?: false`: it is an internal
|
||||||
|
lookup of immutable reference data needed to populate a derived column, not a
|
||||||
|
user-facing read, and cycle creation is itself a systemic operation. Reading it under
|
||||||
|
the caller's actor is unreliable here (the actor is not always present in the change
|
||||||
|
context, e.g. when a changeset is built with `for_create/3` and the actor is only
|
||||||
|
supplied to `Ash.create/2`).
|
||||||
|
|
||||||
|
When `cycle_start` or `membership_fee_type_id` are missing the changeset is returned
|
||||||
|
unchanged so the normal `allow_nil?` validations surface the error.
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def change(changeset, _opts, _context) do
|
||||||
|
with {:ok, cycle_start} <- fetch(changeset, :cycle_start),
|
||||||
|
{:ok, fee_type_id} <- fetch(changeset, :membership_fee_type_id),
|
||||||
|
{:ok, interval} <- resolve_interval(changeset, fee_type_id) do
|
||||||
|
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||||
|
Ash.Changeset.force_change_attribute(changeset, :cycle_end, cycle_end)
|
||||||
|
else
|
||||||
|
_ -> changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch(changeset, field) do
|
||||||
|
case Ash.Changeset.fetch_change(changeset, field) do
|
||||||
|
{:ok, value} when not is_nil(value) ->
|
||||||
|
{:ok, value}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
case Map.get(changeset.data, field) do
|
||||||
|
nil -> :error
|
||||||
|
value -> {:ok, value}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_interval(changeset, fee_type_id) do
|
||||||
|
case Ash.Changeset.get_argument(changeset, :interval) do
|
||||||
|
nil -> fetch_interval(fee_type_id)
|
||||||
|
interval -> {:ok, interval}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_interval(fee_type_id) do
|
||||||
|
case Ash.get(MembershipFeeType, fee_type_id, authorize?: false) do
|
||||||
|
{:ok, %{interval: interval}} -> {:ok, interval}
|
||||||
|
_ -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|> load_custom_field_values_query(custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
|
|
@ -359,10 +359,10 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|
|
||||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
defp maybe_load_cycles(query, false), do: query
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true, show_current) do
|
defp maybe_load_cycles(query, true) do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
MembershipFeeStatus.load_cycles_for_members(query)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_groups(query, false), do: query
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
# Only generate if start_date <= end_date
|
# Only generate if start_date <= end_date
|
||||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||||
create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
|
create_cycles(cycle_starts, member.id, fee_type.id, amount, interval, opts)
|
||||||
else
|
else
|
||||||
{:ok, [], []}
|
{:ok, [], []}
|
||||||
end
|
end
|
||||||
|
|
@ -400,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
|
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do
|
||||||
actor = get_actor(opts)
|
actor = get_actor(opts)
|
||||||
create_opts = Helpers.ash_actor_opts(actor)
|
create_opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
|
@ -414,7 +414,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
member_id: member_id,
|
member_id: member_id,
|
||||||
membership_fee_type_id: fee_type_id,
|
membership_fee_type_id: fee_type_id,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
status: :unpaid
|
status: :unpaid,
|
||||||
|
# Pass the already-resolved interval so SetCycleEnd does not re-fetch the
|
||||||
|
# fee type for every cycle in the batch (avoids an N+1 lookup).
|
||||||
|
interval: interval
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_cycle_creation_result(
|
handle_cycle_creation_result(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule MvWeb.Components.BulkActionsDropdown do
|
defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
|
Single "Actions" dropdown bundling the four member bulk actions, flattened to
|
||||||
one level: open in email program (mailto), copy email addresses, export to CSV,
|
one level: open in email program (mailto), copy email addresses, export to CSV,
|
||||||
export to PDF.
|
export to PDF.
|
||||||
|
|
||||||
|
|
@ -10,10 +10,10 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
|
|
||||||
## Scope and trigger badge
|
## Scope and trigger badge
|
||||||
|
|
||||||
The trigger reads `Aktionen` followed by a scope badge: an emphasized
|
The trigger reads the `Actions` label followed by a scope badge: an emphasized
|
||||||
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
||||||
badge otherwise — `gefiltert` when a search term or filter narrows the list,
|
badge otherwise — `filtered` when a search term or filter narrows the list,
|
||||||
`alle` when nothing is selected and no search/filter is active. Only an actual
|
`all` when nothing is selected and no search/filter is active. Only an actual
|
||||||
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
|
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
|
||||||
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
||||||
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
||||||
|
|
@ -36,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
|
|
||||||
## Event routing
|
## Event routing
|
||||||
|
|
||||||
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
|
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the
|
||||||
component owns its own `:open` state. The copy item carries an *un-targeted*
|
parent LiveView owns the `:open` state (passed back in via the `open` assign).
|
||||||
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
|
This lets the parent fetch the mailto recipients lazily when the dropdown opens
|
||||||
`handle_event/3` (which keeps access to `@members`), plus the
|
rather than on every selection/filter change. The copy item likewise carries an
|
||||||
`CopyToClipboard` hook.
|
un-targeted `phx-click="copy_emails"`, which reaches the parent's
|
||||||
|
`handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard`
|
||||||
|
hook.
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
||||||
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
||||||
|
|
||||||
# The parent never sets :open (the component owns it via toggle/close).
|
# The parent owns :open and passes it in. Defaulting to false keeps the
|
||||||
# Honouring an explicit :open assign keeps the component renderable in
|
# component renderable in isolation (render_component/2) for structural tests
|
||||||
# isolation (render_component/2) for structural tests.
|
# that omit it.
|
||||||
socket =
|
socket =
|
||||||
case Map.fetch(assigns, :open) do
|
case Map.fetch(assigns, :open) do
|
||||||
{:ok, open} -> assign(socket, :open, open)
|
{:ok, open} -> assign(socket, :open, open)
|
||||||
:error -> socket
|
:error -> assign_new(socket, :open, fn -> false end)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
button_label={gettext("Actions")}
|
button_label={gettext("Actions")}
|
||||||
icon="hero-bolt"
|
icon="hero-bolt"
|
||||||
open={@open}
|
open={@open}
|
||||||
phx_target={@myself}
|
phx_target={nil}
|
||||||
menu_width="w-70"
|
menu_width="w-70"
|
||||||
menu_align="left"
|
menu_align="left"
|
||||||
button_class="btn-secondary gap-2"
|
button_class="btn-secondary gap-2"
|
||||||
|
|
@ -214,7 +216,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
||||||
end
|
end
|
||||||
|
|
||||||
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
|
# The trigger scope is shown as a badge after the "Actions" label. Only an
|
||||||
# actual selection is emphasized (primary); both the "filtered" and "all"
|
# actual selection is emphasized (primary); both the "filtered" and "all"
|
||||||
# scopes are muted (neutral), since neither means members are selected.
|
# scopes are muted (neutral), since neither means members are selected.
|
||||||
defp scope_label(assigns) do
|
defp scope_label(assigns) do
|
||||||
|
|
@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
# Open/close are handled by the parent LiveView (see "Event routing"); the
|
||||||
def handle_event("toggle_dropdown", _params, socket) do
|
# component no longer owns dropdown state.
|
||||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("close_dropdown", _params, socket) do
|
|
||||||
{:noreply, assign(socket, :open, false)}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -143,12 +143,18 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
attr :size, :string, values: ~w(sm md lg), default: "md"
|
attr :size, :string, values: ~w(sm md lg), default: "md"
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
|
|
||||||
|
attr :active, :boolean,
|
||||||
|
default: false,
|
||||||
|
doc: "Renders the button in its active/pressed (toggled-on) state"
|
||||||
|
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(assigns) do
|
def button(assigns) do
|
||||||
rest = assigns.rest
|
rest = assigns.rest
|
||||||
variant = assigns[:variant] || "primary"
|
variant = assigns[:variant] || "primary"
|
||||||
size = assigns[:size] || "md"
|
size = assigns[:size] || "md"
|
||||||
|
active_class = if assigns[:active], do: "btn-active", else: ""
|
||||||
|
|
||||||
variant_classes = %{
|
variant_classes = %{
|
||||||
"primary" => "btn-primary",
|
"primary" => "btn-primary",
|
||||||
|
|
@ -169,7 +175,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
base_class = Map.fetch!(variant_classes, variant)
|
base_class = Map.fetch!(variant_classes, variant)
|
||||||
size_class = size_classes[size]
|
size_class = size_classes[size]
|
||||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
|
||||||
|
btn_class =
|
||||||
|
[base_class, size_class, active_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||||
|
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|
|
@ -214,6 +222,27 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a loading spinner for inline "busy" states.
|
||||||
|
|
||||||
|
Wraps the DaisyUI `loading` classes so views never spell them out. Mark it
|
||||||
|
`aria-hidden="true"` when an adjacent text label already conveys the busy state.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.spinner />
|
||||||
|
<.spinner size="lg" aria-hidden="true" />
|
||||||
|
"""
|
||||||
|
attr :size, :string, values: ~w(xs sm md lg), default: "sm"
|
||||||
|
attr :class, :any, default: nil, doc: "Additional layout classes"
|
||||||
|
attr :rest, :global
|
||||||
|
|
||||||
|
def spinner(assigns) do
|
||||||
|
~H"""
|
||||||
|
<span class={["loading loading-spinner", "loading-#{@size}", @class]} {@rest}></span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a non-interactive badge with WCAG-compliant contrast.
|
Renders a non-interactive badge with WCAG-compliant contrast.
|
||||||
|
|
||||||
|
|
@ -250,7 +279,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
attr :size, :any,
|
attr :size, :any,
|
||||||
default: "md",
|
default: "md",
|
||||||
doc: "Badge size: sm | md"
|
doc: "Badge size: xs | sm | md"
|
||||||
|
|
||||||
attr :sr_label, :string,
|
attr :sr_label, :string,
|
||||||
default: nil,
|
default: nil,
|
||||||
|
|
@ -269,7 +298,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
variant_class = "badge-#{variant}"
|
variant_class = "badge-#{variant}"
|
||||||
style_class = badge_style_class(style)
|
style_class = badge_style_class(style)
|
||||||
size_class = "badge-#{size}"
|
size_class = badge_size_class(size)
|
||||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
||||||
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||||
|
|
||||||
|
|
@ -309,6 +338,13 @@ defmodule MvWeb.CoreComponents do
|
||||||
defp badge_style_class("outline"), do: "badge-outline"
|
defp badge_style_class("outline"), do: "badge-outline"
|
||||||
defp badge_style_class(_), do: nil
|
defp badge_style_class(_), do: nil
|
||||||
|
|
||||||
|
# Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg
|
||||||
|
# and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible).
|
||||||
|
defp badge_size_class("xs"), do: "badge-xs"
|
||||||
|
defp badge_size_class("sm"), do: "badge-sm"
|
||||||
|
defp badge_size_class("lg"), do: "badge-lg"
|
||||||
|
defp badge_size_class(_), do: "badge-md"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||||
|
|
||||||
|
|
@ -443,6 +479,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :selected, :map, default: %{}
|
attr :selected, :map, default: %{}
|
||||||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
||||||
|
|
||||||
|
attr :show_reset_button, :boolean,
|
||||||
|
default: false,
|
||||||
|
doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)"
|
||||||
|
|
||||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||||
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
|
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
|
||||||
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
||||||
|
|
@ -546,6 +587,19 @@ defmodule MvWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
{gettext("None")}
|
{gettext("None")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:if={@show_reset_button}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={gettext("Reset to default")}
|
||||||
|
title={gettext("Reset to default")}
|
||||||
|
phx-click="reset_fields"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
data-testid="dropdown-reset"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -938,6 +992,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||||
|
|
||||||
|
attr :size_class, :string,
|
||||||
|
default: "",
|
||||||
|
doc:
|
||||||
|
"optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value"
|
||||||
|
|
||||||
attr :sticky_header, :boolean,
|
attr :sticky_header, :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
doc:
|
doc:
|
||||||
|
|
@ -953,6 +1012,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
doc:
|
doc:
|
||||||
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
|
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
|
||||||
|
|
||||||
|
attr :viewport_bottom, :any,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"optional phx-viewport-bottom event (string) rendered on the streamed tbody so the built-in InfiniteScroll hook appends the next page without shifting the scroll position; nil omits the binding"
|
||||||
|
|
||||||
slot :col, required: true do
|
slot :col, required: true do
|
||||||
attr :label, :string
|
attr :label, :string
|
||||||
attr :class, :string
|
attr :class, :string
|
||||||
|
|
@ -964,6 +1028,10 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||||
|
|
||||||
|
slot :footer,
|
||||||
|
doc:
|
||||||
|
"optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with viewport_bottom it acts as the infinite-scroll sentinel (e.g. a loading indicator) that fires the load event and disappears once no more pages remain."
|
||||||
|
|
||||||
def table(assigns) do
|
def table(assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||||
|
|
@ -986,6 +1054,15 @@ defmodule MvWeb.CoreComponents do
|
||||||
assigns =
|
assigns =
|
||||||
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||||
|
|
||||||
|
# Total column span for full-width footer rows (e.g. the infinite-scroll
|
||||||
|
# loading indicator): fixed columns + dynamic custom-field columns + the
|
||||||
|
# optional action column.
|
||||||
|
col_count =
|
||||||
|
length(assigns.col) + length(assigns[:dynamic_cols] || []) +
|
||||||
|
if assigns.action != [], do: 1, else: 0
|
||||||
|
|
||||||
|
assigns = assign(assigns, :col_count, col_count)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
id={@row_click && "#{@id}-keyboard"}
|
id={@row_click && "#{@id}-keyboard"}
|
||||||
|
|
@ -993,20 +1070,31 @@ defmodule MvWeb.CoreComponents do
|
||||||
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
||||||
phx-hook={@row_click && "TableRowKeydown"}
|
phx-hook={@row_click && "TableRowKeydown"}
|
||||||
>
|
>
|
||||||
<table class="table table-zebra">
|
<table class={["table table-zebra", @size_class]}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||||
class={[
|
class={[
|
||||||
table_th_class(col, @sticky_header),
|
table_th_class(col, @sticky_header),
|
||||||
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
|
@sticky_first_col && col_idx == 0 &&
|
||||||
|
"sticky-first-col-th sticky left-0 z-30 bg-base-100"
|
||||||
]}
|
]}
|
||||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||||
>
|
>
|
||||||
{col[:label]}
|
{col[:label]}
|
||||||
</th>
|
</th>
|
||||||
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
|
<th
|
||||||
|
:for={dyn_col <- @dynamic_cols}
|
||||||
|
class={table_th_sticky_class(@sticky_header)}
|
||||||
|
aria-sort={
|
||||||
|
table_th_aria_sort(
|
||||||
|
%{sort_field: "custom_field_#{dyn_col[:custom_field].id}"},
|
||||||
|
@sort_field,
|
||||||
|
@sort_order
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
|
|
@ -1021,16 +1109,24 @@ defmodule MvWeb.CoreComponents do
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
<tbody
|
||||||
|
id={@id}
|
||||||
|
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
|
||||||
|
>
|
||||||
<tr
|
<tr
|
||||||
:for={row <- @rows}
|
:for={row <- @rows}
|
||||||
id={@row_id && @row_id.(row)}
|
id={@row_id && @row_id.(row)}
|
||||||
class={[
|
class={
|
||||||
|
[
|
||||||
table_row_tr_class(
|
table_row_tr_class(
|
||||||
table_row_selected?(assigns, row),
|
table_row_selected?(assigns, row),
|
||||||
@sticky_first_col
|
@sticky_first_col
|
||||||
)
|
),
|
||||||
]}
|
# WCAG 2.4.11: keep a keyboard-focused row from being hidden under
|
||||||
|
# the sticky header by reserving header-height scroll margin.
|
||||||
|
@sticky_header && "scroll-mt-16"
|
||||||
|
]
|
||||||
|
}
|
||||||
data-row-interactive={@row_click && "true"}
|
data-row-interactive={@row_click && "true"}
|
||||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||||
title={@row_click && @row_tooltip}
|
title={@row_click && @row_tooltip}
|
||||||
|
|
@ -1103,8 +1199,37 @@ defmodule MvWeb.CoreComponents do
|
||||||
<td
|
<td
|
||||||
:for={dyn_col <- @dynamic_cols}
|
:for={dyn_col <- @dynamic_cols}
|
||||||
phx-click={@row_click && @row_click.(row)}
|
phx-click={@row_click && @row_click.(row)}
|
||||||
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
class={[
|
||||||
|
"max-w-xs",
|
||||||
|
dyn_col[:custom_field].value_type != :boolean && "truncate",
|
||||||
|
@row_click && "hover:cursor-pointer"
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
|
<%= if dyn_col[:custom_field].value_type == :boolean do %>
|
||||||
|
<% val = dyn_col[:render] && dyn_col[:render].(@row_item.(row)) %>
|
||||||
|
<%= cond do %>
|
||||||
|
<% val == true -> %>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<.icon
|
||||||
|
name="hero-check-circle"
|
||||||
|
class="size-4 text-success"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="sr-only">{gettext("Yes")}</span>
|
||||||
|
</div>
|
||||||
|
<% val == false -> %>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<.icon
|
||||||
|
name="hero-x-circle"
|
||||||
|
class="size-4 text-error"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="sr-only">{gettext("No")}</span>
|
||||||
|
</div>
|
||||||
|
<% true -> %>
|
||||||
|
<.empty_cell sr_text={gettext("Not specified")} />
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
{if dyn_col[:render] do
|
{if dyn_col[:render] do
|
||||||
rendered = dyn_col[:render].(@row_item.(row))
|
rendered = dyn_col[:render].(@row_item.(row))
|
||||||
|
|
||||||
|
|
@ -1116,6 +1241,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
else
|
else
|
||||||
""
|
""
|
||||||
end}
|
end}
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td :if={@action != []} class="w-0 font-semibold">
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
|
@ -1126,6 +1252,21 @@ defmodule MvWeb.CoreComponents do
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<%!-- After-rows footer in its own, non-streamed tbody so it stays put
|
||||||
|
outside the phx-update="stream" container. When viewport_bottom is set it
|
||||||
|
doubles as the infinite-scroll sentinel: rendered only while more pages
|
||||||
|
exist, so once the last page loads it disappears and stops firing. --%>
|
||||||
|
<tbody
|
||||||
|
:if={@footer != [] && @viewport_bottom}
|
||||||
|
id={"#{@id}-footer"}
|
||||||
|
phx-viewport-bottom={@viewport_bottom}
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td colspan={@col_count}>
|
||||||
|
{render_slot(@footer)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -1160,8 +1301,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
||||||
# hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10)
|
# bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll.
|
||||||
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background.
|
# Sort tooltips use the Popover API (top layer) and need no header z-lift.
|
||||||
defp table_th_class(col, sticky_header) do
|
defp table_th_class(col, sticky_header) do
|
||||||
base = Map.get(col, :class)
|
base = Map.get(col, :class)
|
||||||
sticky = if sticky_header, do: sticky_th_classes(), else: nil
|
sticky = if sticky_header, do: sticky_th_classes(), else: nil
|
||||||
|
|
@ -1173,7 +1314,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
defp table_th_sticky_class(_), do: nil
|
defp table_th_sticky_class(_), do: nil
|
||||||
|
|
||||||
defp sticky_th_classes,
|
defp sticky_th_classes,
|
||||||
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
|
do: "lg:sticky lg:top-0 bg-base-100 z-10"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
|
|
@ -338,10 +338,10 @@ defmodule MvWeb.MemberExportController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
defp maybe_load_cycles(query, false), do: query
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true, show_current) do
|
defp maybe_load_cycles(query, true) do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
MembershipFeeStatus.load_cycles_for_members(query)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_groups(query, false), do: query
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
|
||||||
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
|
|
@ -69,12 +69,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
<.dropdown_menu
|
<.dropdown_menu
|
||||||
id="field-visibility-menu"
|
id="field-visibility-menu"
|
||||||
icon="hero-adjustments-horizontal"
|
icon="hero-adjustments-horizontal"
|
||||||
button_label={gettext("Show/Hide Columns")}
|
button_label={gettext("Columns")}
|
||||||
items={@all_items}
|
items={@all_items}
|
||||||
checkboxes={true}
|
checkboxes={true}
|
||||||
selected={@selected_fields}
|
selected={@selected_fields}
|
||||||
open={@open}
|
open={@open}
|
||||||
show_select_buttons={true}
|
show_select_buttons={true}
|
||||||
|
show_reset_button={true}
|
||||||
phx_target={@myself}
|
phx_target={@myself}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
{:noreply, assign(socket, :selected_fields, all)}
|
{:noreply, assign(socket, :selected_fields, all)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# reset to the curated default column set (the parent owns the settings needed
|
||||||
|
# to compute the default, so it recomputes and re-applies the selection)
|
||||||
|
def handle_event("reset_fields", _params, socket) do
|
||||||
|
send(self(), {:fields_reset})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
# select none
|
# select none
|
||||||
def handle_event("select_none", _params, socket) do
|
def handle_event("select_none", _params, socket) do
|
||||||
none =
|
none =
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
:if={@query not in [nil, ""]}
|
||||||
|
type="button"
|
||||||
|
phx-click="clear_search"
|
||||||
|
phx-target={@myself}
|
||||||
|
data-testid="search-clear"
|
||||||
|
aria-label={gettext("Clear search")}
|
||||||
|
class="opacity-50 hover:opacity-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="h-[1em]" />
|
||||||
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
|
|
@ -61,4 +72,10 @@ defmodule MvWeb.Components.SearchBarComponent do
|
||||||
send(self(), {:search_changed, q})
|
send(self(), {:search_changed, q})
|
||||||
{:noreply, assign(socket, :query, q)}
|
{:noreply, assign(socket, :query, q)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Clears the query and resets the result set to the unfiltered list (§1.3).
|
||||||
|
def handle_event("clear_search", _params, socket) do
|
||||||
|
send(self(), {:search_changed, ""})
|
||||||
|
{:noreply, assign(socket, :query, "")}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,41 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
- label: string() # Column Heading (can be an heex template)
|
- label: string() # Column Heading (can be an heex template)
|
||||||
- sort_field: atom() | nil # current sort field from parent liveview
|
- sort_field: atom() | nil # current sort field from parent liveview
|
||||||
- sort_order: :asc | :desc | nil # current sorting order
|
- sort_order: :asc | :desc | nil # current sorting order
|
||||||
|
- sort_hint: string() | nil # optional; overrides the generic "click
|
||||||
|
# to sort" tooltip/aria-label for columns
|
||||||
|
# whose sort key is non-obvious (e.g. the
|
||||||
|
# Name column sorts by last name)
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
{:ok, assign(socket, assigns)}
|
{:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if we can add the aria-sort label directly to the daisyUI header
|
# Check if we can add the aria-sort label directly to the daisyUI header
|
||||||
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
|
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|
||||||
|
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div>
|
<div>
|
||||||
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
|
<button
|
||||||
<.button
|
id={"sort-btn-#{@field}"}
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
|
||||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
aria-describedby={@tooltip_id}
|
||||||
class="select-none"
|
class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
|
||||||
phx-click="sort"
|
phx-click="sort"
|
||||||
phx-value-field={@field}
|
phx-value-field={@field}
|
||||||
|
phx-hook="SortTooltip"
|
||||||
|
data-tooltip-id={@tooltip_id}
|
||||||
data-testid={@field}
|
data-testid={@field}
|
||||||
>
|
>
|
||||||
{@label}
|
{@label}
|
||||||
<%= if @sort_field == @field do %>
|
<%= if @sort_field == @field do %>
|
||||||
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
<.icon
|
||||||
|
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
|
||||||
|
class="sort-icon"
|
||||||
|
/>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.icon
|
<.icon
|
||||||
name="hero-chevron-up-down"
|
name="hero-chevron-up-down"
|
||||||
class="opacity-40"
|
class="sort-icon opacity-40"
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.button>
|
</button>
|
||||||
</.tooltip>
|
<div id={@tooltip_id} popover="manual" role="tooltip" class="sort-tooltip">
|
||||||
|
{aria_sort(@field, @sort_field, @sort_order, @hint)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -48,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
defp aria_sort(field, sort_field, dir) when field == sort_field do
|
defp aria_sort(field, sort_field, dir, hint) when field == sort_field do
|
||||||
case dir do
|
case dir do
|
||||||
:asc -> gettext("ascending")
|
:asc -> gettext("ascending")
|
||||||
:desc -> gettext("descending")
|
:desc -> gettext("descending")
|
||||||
nil -> gettext("Click to sort")
|
nil -> hint
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp aria_sort(_, _, _), do: gettext("Click to sort")
|
defp aria_sort(_, _, _, hint), do: hint
|
||||||
end
|
end
|
||||||
|
|
|
||||||
129
lib/mv_web/live/components/view_settings_dropdown_component.ex
Normal file
129
lib/mv_web/live/components/view_settings_dropdown_component.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
defmodule MvWeb.Components.ViewSettingsDropdownComponent do
|
||||||
|
@moduledoc """
|
||||||
|
LiveComponent for the member-overview view settings (§1.20).
|
||||||
|
|
||||||
|
Renders an icon button with a tooltip that opens a dropdown of toggles:
|
||||||
|
|
||||||
|
* Compact mode — table row density (compact ↔ comfortable)
|
||||||
|
* Compact Member field — composite last+first name cell, with a sub-toggle
|
||||||
|
"include email"
|
||||||
|
* Compact address field — composite street / postal+city cell
|
||||||
|
|
||||||
|
Turning a composite field off surfaces the underlying separate columns in the
|
||||||
|
table. The component is display-only: it emits `{:view_setting_toggled, key}`
|
||||||
|
to the parent LiveView, which owns and persists the settings.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
- `:density` — `:compact` or `:comfortable`
|
||||||
|
- `:compact_member` — boolean
|
||||||
|
- `:member_include_email` — boolean
|
||||||
|
- `:compact_address` — boolean
|
||||||
|
- `:id` — component id
|
||||||
|
"""
|
||||||
|
|
||||||
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(assigns, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign_new(:open, fn -> false end)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div>
|
||||||
|
<.dropdown_menu
|
||||||
|
id="view-settings-menu"
|
||||||
|
icon="hero-cog-6-tooth"
|
||||||
|
button_label={gettext("View")}
|
||||||
|
open={@open}
|
||||||
|
phx_target={@myself}
|
||||||
|
menu_width="w-72"
|
||||||
|
testid="view-settings"
|
||||||
|
button_testid="view-settings-button"
|
||||||
|
>
|
||||||
|
<li role="none" class="mb-1 px-2">
|
||||||
|
<span class="font-semibold">{gettext("View settings")}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<.toggle_item
|
||||||
|
setting="density"
|
||||||
|
label={gettext("Compact mode")}
|
||||||
|
checked={@density == :compact}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_item
|
||||||
|
setting="compact_member"
|
||||||
|
label={gettext("Compact Member field")}
|
||||||
|
checked={@compact_member}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%!-- The email line lives inside the composite Member cell, so this
|
||||||
|
sub-toggle is only meaningful while that cell is compact. When the
|
||||||
|
composite is off, email is its own column and this toggle is omitted. --%>
|
||||||
|
<li :if={@compact_member} role="none" class="pl-6">
|
||||||
|
<.toggle_item
|
||||||
|
setting="member_include_email"
|
||||||
|
label={gettext("include email")}
|
||||||
|
checked={@member_include_email}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<.toggle_item
|
||||||
|
setting="compact_address"
|
||||||
|
label={gettext("Compact address field")}
|
||||||
|
checked={@compact_address}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
</.dropdown_menu>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :setting, :string, required: true
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :checked, :boolean, required: true
|
||||||
|
attr :target, :any, required: true
|
||||||
|
|
||||||
|
defp toggle_item(assigns) do
|
||||||
|
~H"""
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={to_string(@checked)}
|
||||||
|
aria-label={@label}
|
||||||
|
tabindex="0"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded w-full text-left cursor-pointer hover:bg-base-200"
|
||||||
|
phx-click="toggle"
|
||||||
|
phx-value-setting={@setting}
|
||||||
|
phx-target={@target}
|
||||||
|
data-testid={"view-setting-#{String.replace(@setting, "_", "-")}"}
|
||||||
|
>
|
||||||
|
<span class={if @checked, do: "text-primary", else: "text-base-300"} aria-hidden="true">
|
||||||
|
<.icon name="hero-check" class="size-4 shrink-0" />
|
||||||
|
</span>
|
||||||
|
<span>{@label}</span>
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_dropdown", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("close_dropdown", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :open, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle", %{"setting" => setting}, socket) do
|
||||||
|
send(self(), {:view_setting_toggled, String.to_existing_atom(setting)})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.BulkActionsDropdown}
|
module={MvWeb.Components.BulkActionsDropdown}
|
||||||
id="bulk-actions-dropdown"
|
id="bulk-actions-dropdown"
|
||||||
|
open={@bulk_actions_open}
|
||||||
export_payload_json={@export_payload_json}
|
export_payload_json={@export_payload_json}
|
||||||
selected_count={@selected_count}
|
selected_count={@selected_count}
|
||||||
scope={@scope}
|
scope={@scope}
|
||||||
|
|
@ -20,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}
|
||||||
|
|
@ -27,10 +31,28 @@
|
||||||
query={@query}
|
query={@query}
|
||||||
placeholder={gettext("Search...")}
|
placeholder={gettext("Search...")}
|
||||||
/>
|
/>
|
||||||
|
<div class="ml-auto flex items-center gap-4">
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.MemberFilterComponent}
|
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||||
|
id="field-visibility-dropdown"
|
||||||
|
all_fields={@all_available_fields}
|
||||||
|
custom_fields={@all_custom_fields}
|
||||||
|
selected_fields={@user_field_selection}
|
||||||
|
/>
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.ViewSettingsDropdownComponent}
|
||||||
|
id="view-settings-dropdown"
|
||||||
|
density={@density}
|
||||||
|
compact_member={@compact_member}
|
||||||
|
member_include_email={@member_include_email}
|
||||||
|
compact_address={@compact_address}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.AddFilterBuilderComponent}
|
||||||
id="member-filter"
|
id="member-filter"
|
||||||
cycle_status_filter={@cycle_status_filter}
|
|
||||||
groups={@groups}
|
groups={@groups}
|
||||||
group_filters={@group_filters}
|
group_filters={@group_filters}
|
||||||
fee_types={@fee_types}
|
fee_types={@fee_types}
|
||||||
|
|
@ -39,45 +61,24 @@
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
date_custom_fields={@date_custom_fields}
|
date_custom_fields={@date_custom_fields}
|
||||||
date_filters={@date_filters}
|
date_filters={@date_filters}
|
||||||
member_count={length(@members)}
|
payment_filter={@payment_filter}
|
||||||
|
payment_period={@payment_period}
|
||||||
|
suspended={@suspended}
|
||||||
|
as_of_date={@as_of_date}
|
||||||
/>
|
/>
|
||||||
<.tooltip
|
</div>
|
||||||
content={
|
|
||||||
gettext(
|
<%!-- Polite live region: present on first render (before it is filled) so
|
||||||
"Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
screen readers announce the exact total matching count on every filter change
|
||||||
)
|
(WCAG 4.1.3). --%>
|
||||||
}
|
<div
|
||||||
position="top"
|
id="members-result-count"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
class="sr-only"
|
||||||
>
|
>
|
||||||
<.button
|
{ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)}
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
class={["gap-2", @show_current_cycle && "btn-active"]}
|
|
||||||
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>
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
|
||||||
id="field-visibility-dropdown"
|
|
||||||
all_fields={@all_available_fields}
|
|
||||||
custom_fields={@all_custom_fields}
|
|
||||||
selected_fields={@user_field_selection}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
||||||
|
|
@ -88,50 +89,98 @@
|
||||||
data-testid="members-table-scroll"
|
data-testid="members-table-scroll"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={gettext("Members table")}
|
aria-label={gettext("Members table")}
|
||||||
|
aria-busy={to_string(@loading?)}
|
||||||
|
data-density={@density}
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@streams.members}
|
||||||
|
row_item={fn {_dom_id, member} -> member end}
|
||||||
wrapper_overflow_class="overflow-visible"
|
wrapper_overflow_class="overflow-visible"
|
||||||
sticky_header={true}
|
sticky_header={true}
|
||||||
sticky_first_col={true}
|
sticky_first_col={true}
|
||||||
row_id={fn member -> "row-#{member.id}" end}
|
viewport_bottom={@more? && "load_more"}
|
||||||
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
row_click={
|
||||||
row_tooltip={gettext("Click for member details")}
|
fn {_dom_id, member} ->
|
||||||
|
JS.push("select_row_and_navigate", value: %{id: member.id})
|
||||||
|
end
|
||||||
|
}
|
||||||
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}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
|
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
id="select-all-checkbox"
|
||||||
name="select_all"
|
name="select_all"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
phx-click="select_all"
|
phx-click="select_all"
|
||||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
phx-hook="IndeterminateCheckbox"
|
||||||
|
data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)}
|
||||||
|
checked={@total_count > 0 and @selected_count == @total_count}
|
||||||
aria-label={gettext("Select all members")}
|
aria-label={gettext("Select all members")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<.input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={member.id}
|
name={member.id}
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
checked={MapSet.member?(@selected_members, member.id)}
|
checked={MapSet.member?(@selected_members, member.id)}
|
||||||
aria-label={gettext("Select member")}
|
aria-label={gettext("Select member")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:name in @member_fields_visible and @compact_member}
|
||||||
|
sort_field={:name}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_name}
|
||||||
|
field={:name}
|
||||||
|
label={gettext("Name")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
sort_hint={gettext("Click to sort by last name")}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<% full_name =
|
||||||
|
[member.first_name, member.last_name]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.join(" ") %>
|
||||||
|
<div data-testid="member-name">
|
||||||
|
<div class="font-medium truncate" title={full_name}>
|
||||||
|
{full_name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:if={@member_include_email and member.email not in [nil, ""]}
|
||||||
|
data-testid="member-name-email"
|
||||||
|
class="opacity-70 truncate"
|
||||||
|
title={member.email}
|
||||||
|
>
|
||||||
|
{member.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:first_name in @member_fields_visible}
|
:if={:first_name in @member_fields_visible}
|
||||||
|
sort_field={:first_name}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -150,6 +199,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:last_name in @member_fields_visible}
|
:if={:last_name in @member_fields_visible}
|
||||||
|
sort_field={:last_name}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -168,6 +218,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:email in @member_fields_visible}
|
:if={:email in @member_fields_visible}
|
||||||
|
sort_field={:email}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -183,9 +234,127 @@
|
||||||
>
|
>
|
||||||
{member.email}
|
{member.email}
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:address in @member_fields_visible and @compact_address}
|
||||||
|
sort_field={:address}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_address}
|
||||||
|
field={:address}
|
||||||
|
label={gettext("Address")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
sort_hint={gettext("Click to sort by city")}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<% line1 =
|
||||||
|
[member.street, member.house_number]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.join(" ") %>
|
||||||
|
<% line2 =
|
||||||
|
[member.postal_code, member.city]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.join(" ") %>
|
||||||
|
<.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
|
||||||
|
<div data-testid="member-address">
|
||||||
|
<div class="truncate" title={line1}>{line1}</div>
|
||||||
|
<div class="opacity-70 truncate" title={line2}>{line2}</div>
|
||||||
|
</div>
|
||||||
|
</.maybe_value>
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:street in @member_fields_visible}
|
||||||
|
sort_field={:street}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_street}
|
||||||
|
field={:street}
|
||||||
|
label={gettext("Street")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
|
||||||
|
{member.street}
|
||||||
|
</.maybe_value>
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:house_number in @member_fields_visible}
|
||||||
|
sort_field={:house_number}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_house_number}
|
||||||
|
field={:house_number}
|
||||||
|
label={gettext("House Number")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
|
||||||
|
{member.house_number}
|
||||||
|
</.maybe_value>
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:postal_code in @member_fields_visible}
|
||||||
|
sort_field={:postal_code}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_postal_code}
|
||||||
|
field={:postal_code}
|
||||||
|
label={gettext("Postal Code")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
|
||||||
|
{member.postal_code}
|
||||||
|
</.maybe_value>
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:city in @member_fields_visible}
|
||||||
|
sort_field={:city}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_city}
|
||||||
|
field={:city}
|
||||||
|
label={gettext("City")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
|
||||||
|
{member.city}
|
||||||
|
</.maybe_value>
|
||||||
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:join_date in @member_fields_visible}
|
:if={:join_date in @member_fields_visible}
|
||||||
|
sort_field={:join_date}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -204,6 +373,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:exit_date in @member_fields_visible}
|
:if={:exit_date in @member_fields_visible}
|
||||||
|
sort_field={:exit_date}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -229,6 +399,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:country in @member_fields_visible}
|
:if={:country in @member_fields_visible}
|
||||||
|
sort_field={:country}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -244,81 +415,10 @@
|
||||||
>
|
>
|
||||||
{member.country}
|
{member.country}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:city in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_city}
|
|
||||||
field={:city}
|
|
||||||
label={gettext("City")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{member.city}
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:street in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_street}
|
|
||||||
field={:street}
|
|
||||||
label={gettext("Street")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{member.street}
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:house_number in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_house_number}
|
|
||||||
field={:house_number}
|
|
||||||
label={gettext("House Number")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{member.house_number}
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:postal_code in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_postal_code}
|
|
||||||
field={:postal_code}
|
|
||||||
label={gettext("Postal Code")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{member.postal_code}
|
|
||||||
</:col>
|
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_start_date in @member_fields_visible}
|
:if={:membership_fee_start_date in @member_fields_visible}
|
||||||
|
sort_field={:membership_fee_start_date}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -337,6 +437,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_type in @member_fields_visible}
|
:if={:membership_fee_type in @member_fields_visible}
|
||||||
|
sort_field={:membership_fee_type}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -357,22 +458,122 @@
|
||||||
<: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
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:groups in @member_fields_visible}
|
:if={:groups in @member_fields_visible}
|
||||||
|
sort_field={:groups}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -387,6 +588,7 @@
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||||
|
<div class="flex flex-wrap gap-1 w-max">
|
||||||
<%= for group <- (member.groups || []) do %>
|
<%= for group <- (member.groups || []) do %>
|
||||||
<.badge
|
<.badge
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -396,6 +598,7 @@
|
||||||
{group.name}
|
{group.name}
|
||||||
</.badge>
|
</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</.maybe_value>
|
</.maybe_value>
|
||||||
</:col>
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
|
|
@ -405,6 +608,37 @@
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</:action>
|
</:action>
|
||||||
|
<:footer>
|
||||||
|
<%!-- Invisible sentinel: starts the next-page load as it approaches the
|
||||||
|
viewport (see the LoadMorePrefetch hook), so rows load ahead of reaching
|
||||||
|
the very bottom. Only rendered while more pages exist (with the footer). --%>
|
||||||
|
<div
|
||||||
|
id="members-load-more-sentinel"
|
||||||
|
phx-hook="LoadMorePrefetch"
|
||||||
|
data-scroll-container="members-table-guard"
|
||||||
|
data-event="load_more"
|
||||||
|
data-margin="600px"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-0"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<%!-- The accent (background + top separator) and row height live on the
|
||||||
|
full-width footer cell (see #members-footer in app.css) so they span the
|
||||||
|
whole table edge-to-edge and match the active density. This inner element
|
||||||
|
stays pinned to the visible viewport width via StickyViewportWidth and is
|
||||||
|
transparent, so the cell accent shows through while the spinner/label stay
|
||||||
|
centered during horizontal scroll. --%>
|
||||||
|
<div
|
||||||
|
id="members-loading-bar"
|
||||||
|
phx-hook="StickyViewportWidth"
|
||||||
|
data-scroll-container="members-table-guard"
|
||||||
|
class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70"
|
||||||
|
data-testid="members-loading-row"
|
||||||
|
>
|
||||||
|
<.spinner size="sm" aria-hidden="true" />
|
||||||
|
{gettext("Loading more members …")}
|
||||||
|
</div>
|
||||||
|
</:footer>
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
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
|
||||||
28
lib/mv_web/live/member_live/index/cookie.ex
Normal file
28
lib/mv_web/live/member_live/index/cookie.ex
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.Cookie do
|
||||||
|
@moduledoc """
|
||||||
|
Shared cookie-header parsing for the member-overview persistence chain.
|
||||||
|
|
||||||
|
Both `MvWeb.MemberLive.Index.ViewSettings` and
|
||||||
|
`MvWeb.MemberLive.Index.FieldSelection` read their persisted value from the
|
||||||
|
raw request `Cookie:` header on the disconnected mount, so the parsing lives
|
||||||
|
here once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a raw `Cookie:` request-header string into a `%{name => value}` map,
|
||||||
|
URL-decoding each value. A valueless entry (`"key"`) maps to `""`; malformed
|
||||||
|
segments are skipped.
|
||||||
|
"""
|
||||||
|
@spec parse_header(String.t()) :: %{String.t() => String.t()}
|
||||||
|
def parse_header(cookie_header) when is_binary(cookie_header) do
|
||||||
|
cookie_header
|
||||||
|
|> String.split(";")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||||
|
|> Enum.reduce(%{}, fn
|
||||||
|
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
||||||
|
[key], acc -> Map.put(acc, key, "")
|
||||||
|
_, acc -> acc
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
@ -271,6 +317,54 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
DB-side equivalent of the custom-date portion of `apply_in_memory/3`.
|
||||||
|
|
||||||
|
For each active custom-date filter, adds a predicate keeping members that have
|
||||||
|
a stored date value for the field within the inclusive `[from, to]` bounds.
|
||||||
|
The value is read from the JSONB union (`value->>'type' = 'date'`, then
|
||||||
|
`(value->>'value')::date`). Members without a date value row for the field are
|
||||||
|
excluded, mirroring the in-memory behaviour exactly.
|
||||||
|
"""
|
||||||
|
@spec apply_custom_date_ash_filter(Ash.Query.t(), map(), [map()]) :: Ash.Query.t()
|
||||||
|
def apply_custom_date_ash_filter(%Ash.Query{} = query, filters, date_custom_fields)
|
||||||
|
when is_map(filters) and is_list(date_custom_fields) do
|
||||||
|
filters
|
||||||
|
|> active_custom_date_filters(date_custom_fields)
|
||||||
|
|> Enum.reduce(query, fn {id, bounds}, q -> apply_one_custom_date_filter(q, id, bounds) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_one_custom_date_filter(query, id, %{from: from, to: to}) do
|
||||||
|
case Ecto.UUID.cast(id) do
|
||||||
|
{:ok, uuid} -> filter_custom_date(query, uuid, from, to)
|
||||||
|
_ -> query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_custom_date(query, uuid, from, to) do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(
|
||||||
|
exists(
|
||||||
|
custom_field_values,
|
||||||
|
custom_field_id == ^uuid and
|
||||||
|
fragment("?->>'type' = 'date'", value) and
|
||||||
|
^custom_date_bounds(from, to)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp custom_date_bounds(nil, to), do: expr(fragment("(?->>'value')::date <= ?", value, ^to))
|
||||||
|
defp custom_date_bounds(from, nil), do: expr(fragment("(?->>'value')::date >= ?", value, ^from))
|
||||||
|
|
||||||
|
defp custom_date_bounds(from, to) do
|
||||||
|
expr(
|
||||||
|
fragment("(?->>'value')::date >= ?", value, ^from) and
|
||||||
|
fragment("(?->>'value')::date <= ?", value, ^to)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the UUID string keys of `filters` that name an active (at-least-one-
|
Returns the UUID string keys of `filters` that name an active (at-least-one-
|
||||||
bound-set) custom date field. The UUID must appear in `date_custom_fields`
|
bound-set) custom date field. The UUID must appear in `date_custom_fields`
|
||||||
|
|
|
||||||
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
|
||||||
162
lib/mv_web/live/member_live/index/export_payload.ex
Normal file
162
lib/mv_web/live/member_live/index/export_payload.ex
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ExportPayload do
|
||||||
|
@moduledoc """
|
||||||
|
Builds the member-overview export descriptor from the LiveView assigns.
|
||||||
|
|
||||||
|
The overview hands this payload (as JSON) to the export flow so a CSV export
|
||||||
|
reproduces exactly the columns, order, filters, and sort the user currently
|
||||||
|
sees. It is pure: it reads the overview assigns and returns a plain map; the
|
||||||
|
LiveView owns encoding and assigning it.
|
||||||
|
|
||||||
|
Column order mirrors the rendered table:
|
||||||
|
|
||||||
|
* DB member fields in `Mv.Constants.member_fields/0` order;
|
||||||
|
* `membership_fee_type` / `membership_fee_status` inserted right after
|
||||||
|
`membership_fee_start_date` when visible;
|
||||||
|
* `groups` appended before the custom fields when visible;
|
||||||
|
* custom fields in the table's custom-field order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the export descriptor map from the overview assigns.
|
||||||
|
"""
|
||||||
|
@spec build(map()) :: map()
|
||||||
|
def build(assigns) do
|
||||||
|
visible_custom_field_ids = assigns[:visible_custom_field_ids] || []
|
||||||
|
|
||||||
|
member_fields_db = assigns[:member_fields_visible_db] || []
|
||||||
|
member_fields_computed = assigns[:member_fields_visible_computed] || []
|
||||||
|
member_fields_visible = assigns[:member_fields_visible] || []
|
||||||
|
|
||||||
|
# Order DB member fields exactly like the table/constants.
|
||||||
|
ordered_member_fields_db =
|
||||||
|
Mv.Constants.member_fields()
|
||||||
|
|> Enum.filter(&(&1 in member_fields_db))
|
||||||
|
|
||||||
|
# Order computed fields in canonical order.
|
||||||
|
ordered_computed_fields =
|
||||||
|
FieldVisibility.computed_member_fields()
|
||||||
|
|> Enum.filter(&(&1 in member_fields_computed))
|
||||||
|
|
||||||
|
member_fields_with_groups =
|
||||||
|
build_member_fields_list(ordered_member_fields_db, member_fields_visible)
|
||||||
|
|
||||||
|
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order).
|
||||||
|
ordered_custom_field_ids =
|
||||||
|
assigns.all_custom_fields
|
||||||
|
|> Enum.map(&to_string(&1.id))
|
||||||
|
|> Enum.filter(&(&1 in visible_custom_field_ids))
|
||||||
|
|
||||||
|
%{
|
||||||
|
selected_ids: assigns.selected_members |> MapSet.to_list(),
|
||||||
|
member_fields:
|
||||||
|
Enum.map(member_fields_with_groups, fn
|
||||||
|
f when is_atom(f) -> Atom.to_string(f)
|
||||||
|
f when is_binary(f) -> f
|
||||||
|
end),
|
||||||
|
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||||||
|
custom_field_ids: ordered_custom_field_ids,
|
||||||
|
column_order:
|
||||||
|
column_order(
|
||||||
|
ordered_member_fields_db,
|
||||||
|
ordered_computed_fields,
|
||||||
|
ordered_custom_field_ids,
|
||||||
|
:membership_fee_type in member_fields_visible,
|
||||||
|
:groups in member_fields_visible
|
||||||
|
),
|
||||||
|
query: assigns[:query] || nil,
|
||||||
|
sort_field: sort_field(assigns[:sort_field]),
|
||||||
|
sort_order: sort_order(assigns[:sort_order]),
|
||||||
|
# Decoupled from the removed overview current/last cycle toggle (§3.3): the
|
||||||
|
# export's fee-status column always uses the current cycle as its basis and
|
||||||
|
# applies no cycle-status row filter (the overview now filters payment via
|
||||||
|
# the period-scoped aging model, which the export does not mirror).
|
||||||
|
show_current_cycle: true,
|
||||||
|
cycle_status_filter: nil,
|
||||||
|
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Serializes a sort field (atom or string) to its export string, or nil."
|
||||||
|
@spec sort_field(atom() | String.t() | nil) :: String.t() | nil
|
||||||
|
def sort_field(nil), do: nil
|
||||||
|
def sort_field(f) when is_atom(f), do: Atom.to_string(f)
|
||||||
|
def sort_field(f) when is_binary(f), do: f
|
||||||
|
|
||||||
|
@doc "Serializes a sort order (atom or string) to its export string, or nil."
|
||||||
|
@spec sort_order(atom() | String.t() | nil) :: String.t() | nil
|
||||||
|
def sort_order(nil), do: nil
|
||||||
|
def sort_order(:asc), do: "asc"
|
||||||
|
def sort_order(:desc), do: "desc"
|
||||||
|
def sort_order(o) when is_binary(o), do: o
|
||||||
|
|
||||||
|
defp build_member_fields_list(ordered_db, member_fields_visible) do
|
||||||
|
member_fields_visible = member_fields_visible || []
|
||||||
|
|
||||||
|
with_extras =
|
||||||
|
Enum.flat_map(ordered_db, fn f ->
|
||||||
|
if f == :membership_fee_start_date and :membership_fee_type in member_fields_visible do
|
||||||
|
[f, :membership_fee_type]
|
||||||
|
else
|
||||||
|
[f]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, append it.
|
||||||
|
with_extras =
|
||||||
|
if :membership_fee_type in member_fields_visible and
|
||||||
|
:membership_fee_type not in with_extras do
|
||||||
|
with_extras ++ [:membership_fee_type]
|
||||||
|
else
|
||||||
|
with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
if :groups in member_fields_visible, do: with_extras ++ [:groups], else: with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
defp expand_db_string(f, membership_fee_type_visible, computed_strings) do
|
||||||
|
if f == "membership_fee_start_date" do
|
||||||
|
extra =
|
||||||
|
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
||||||
|
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
||||||
|
|
||||||
|
[f] ++ extra
|
||||||
|
else
|
||||||
|
[f]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp column_order(
|
||||||
|
ordered_member_fields_db,
|
||||||
|
ordered_computed_fields,
|
||||||
|
ordered_custom_field_ids,
|
||||||
|
membership_fee_type_visible,
|
||||||
|
groups_visible
|
||||||
|
) do
|
||||||
|
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
||||||
|
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
||||||
|
|
||||||
|
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present.
|
||||||
|
db_with_extras =
|
||||||
|
Enum.flat_map(
|
||||||
|
db_strings,
|
||||||
|
&expand_db_string(&1, membership_fee_type_visible, computed_strings)
|
||||||
|
)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, append it before computed/groups.
|
||||||
|
db_with_extras =
|
||||||
|
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
||||||
|
db_with_extras ++ ["membership_fee_type"]
|
||||||
|
else
|
||||||
|
db_with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
# Any remaining computed fields not inserted above (future-proof).
|
||||||
|
remaining_computed = Enum.reject(computed_strings, &(&1 in db_with_extras))
|
||||||
|
|
||||||
|
result = db_with_extras ++ remaining_computed
|
||||||
|
result = if groups_visible, do: result ++ ["groups"], else: result
|
||||||
|
result ++ ordered_custom_field_ids
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -29,6 +29,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.Cookie
|
||||||
|
|
||||||
@cookie_name "member_field_selection"
|
@cookie_name "member_field_selection"
|
||||||
@cookie_max_age 365 * 24 * 60 * 60
|
@cookie_max_age 365 * 24 * 60 * 60
|
||||||
@session_key "member_field_selection"
|
@session_key "member_field_selection"
|
||||||
|
|
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
%{}
|
%{}
|
||||||
|
|
||||||
[cookie_header | _rest] ->
|
[cookie_header | _rest] ->
|
||||||
cookies = parse_cookie_header(cookie_header)
|
cookies = Cookie.parse_header(cookie_header)
|
||||||
|
|
||||||
case Map.get(cookies, @cookie_name) do
|
case Map.get(cookies, @cookie_name) do
|
||||||
nil -> %{}
|
nil -> %{}
|
||||||
|
|
@ -90,19 +92,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Parses cookie header string into a map
|
|
||||||
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
|
||||||
cookie_header
|
|
||||||
|> String.split(";")
|
|
||||||
|> Enum.map(&String.trim/1)
|
|
||||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
|
||||||
|> Enum.reduce(%{}, fn
|
|
||||||
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
|
||||||
[key], acc -> Map.put(acc, key, "")
|
|
||||||
_, acc -> acc
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Saves field selection to cookie.
|
Saves field selection to cookie.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,51 +29,183 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
|
|
||||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
||||||
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
||||||
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
|
# :name and :address are composite display-only columns (Name = name + email,
|
||||||
|
# Address = street/house number over postal code/city).
|
||||||
|
# Order mirrors the overview table / export column order (fee type before fee
|
||||||
|
# status), so the Columns dropdown and the table agree.
|
||||||
|
@pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address]
|
||||||
|
|
||||||
|
# The composite display columns and the constituent columns they replace. The
|
||||||
|
# column manager offers exactly one variant per group, selected by the view
|
||||||
|
# settings: the composite when the matching "compact" setting is on, otherwise
|
||||||
|
# the constituents (see `offered_member_fields/2`).
|
||||||
|
@name_composite :name
|
||||||
|
@name_constituents [:first_name, :last_name, :email]
|
||||||
|
@address_composite :address
|
||||||
|
@address_constituents [:street, :house_number, :postal_code, :city]
|
||||||
|
|
||||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||||
@export_only_alias :payment_status
|
@export_only_alias :payment_status
|
||||||
|
|
||||||
|
# Curated default-visible column set (§1.2): the columns shown on first visit
|
||||||
|
# when there is no persisted selection and no global override. Everything else
|
||||||
|
# (the individual name/address sub-fields, notes, dates other than join date)
|
||||||
|
# defaults to hidden so the table fits common desktop widths.
|
||||||
|
@default_visible_fields MapSet.new([
|
||||||
|
:name,
|
||||||
|
:address,
|
||||||
|
:membership_fee_type,
|
||||||
|
:membership_fee_status,
|
||||||
|
:groups,
|
||||||
|
:join_date
|
||||||
|
])
|
||||||
|
|
||||||
|
# The curated default-visible column set for the given view settings. In
|
||||||
|
# compact mode the composite `:name` / `:address` columns are default; when a
|
||||||
|
# composite is switched off, its constituent columns take over as the defaults
|
||||||
|
# instead (§7b). While the Member composite is compact, `include_email` decides
|
||||||
|
# where the email lives: folded into the Member cell (`true`, no separate
|
||||||
|
# column) or as a default-visible `:email` column (`false`).
|
||||||
|
defp default_visible_set(compact_member, include_email, compact_address) do
|
||||||
|
@default_visible_fields
|
||||||
|
|> apply_name_default(compact_member, include_email)
|
||||||
|
|> apply_group_default(compact_address, @address_composite, @address_constituents)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact Member cell with email folded in: only the composite is default.
|
||||||
|
defp apply_name_default(set, true = _compact, true = _include_email), do: set
|
||||||
|
|
||||||
|
# Compact Member cell without folded email: the composite plus a separate
|
||||||
|
# default-visible email column.
|
||||||
|
defp apply_name_default(set, true = _compact, false = _include_email),
|
||||||
|
do: MapSet.put(set, :email)
|
||||||
|
|
||||||
|
# Non-compact: drop the composite and make first/last name and email default.
|
||||||
|
defp apply_name_default(set, false = _compact, _include_email) do
|
||||||
|
set
|
||||||
|
|> MapSet.delete(@name_composite)
|
||||||
|
|> MapSet.union(MapSet.new(@name_constituents))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact: the composite stays the default. Non-compact: drop the composite and
|
||||||
|
# make the constituents default-visible.
|
||||||
|
defp apply_group_default(set, true, _composite, _constituents), do: set
|
||||||
|
|
||||||
|
defp apply_group_default(set, false, composite, constituents) do
|
||||||
|
set
|
||||||
|
|> MapSet.delete(composite)
|
||||||
|
|> MapSet.union(MapSet.new(constituents))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Member-field atoms the column manager offers for the given view settings, in
|
||||||
|
dropdown/table order.
|
||||||
|
|
||||||
|
The identity block always leads, regardless of whether each group is compact:
|
||||||
|
|
||||||
|
1. the name group — the composite `:name` when compact, otherwise its
|
||||||
|
constituents `:first_name`, `:last_name`;
|
||||||
|
2. the `:email` column, when offered as a separate column (it is folded into
|
||||||
|
the Member cell only while the composite is compact and `include_email` is
|
||||||
|
on);
|
||||||
|
3. the address group — the composite `:address` when compact, otherwise its
|
||||||
|
constituents `:street`, `:house_number`, `:postal_code`, `:city`.
|
||||||
|
|
||||||
|
Everything else follows in the configured data-fields order. This keeps the
|
||||||
|
name group ahead of the address group even when the name group is expanded
|
||||||
|
into `:first_name`/`:last_name` while the address stays compact.
|
||||||
|
"""
|
||||||
|
@spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()]
|
||||||
|
def offered_member_fields(compact_member, include_email, compact_address) do
|
||||||
|
name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name]
|
||||||
|
email_group = if email_offered?(compact_member, include_email), do: [:email], else: []
|
||||||
|
|
||||||
|
address_group =
|
||||||
|
if compact_address, do: [@address_composite], else: @address_constituents
|
||||||
|
|
||||||
|
leading = name_group ++ email_group ++ address_group
|
||||||
|
|
||||||
|
rest =
|
||||||
|
Enum.reject(overview_member_fields(), fn field ->
|
||||||
|
field == @export_only_alias or
|
||||||
|
hidden_variant?(field, compact_member, include_email, compact_address) or
|
||||||
|
field in leading
|
||||||
|
end)
|
||||||
|
|
||||||
|
leading ++ rest
|
||||||
|
end
|
||||||
|
|
||||||
|
# The email is a separate offered column except when it is folded into the
|
||||||
|
# compact Member cell (compact + include_email).
|
||||||
|
defp email_offered?(compact_member, include_email), do: not (compact_member and include_email)
|
||||||
|
|
||||||
|
# The variant of a composite group that is not offered in the current mode.
|
||||||
|
defp hidden_variant?(field, compact_member, include_email, compact_address) do
|
||||||
|
name_variant_hidden?(field, compact_member, include_email) or
|
||||||
|
address_variant_hidden?(field, compact_address)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact hides first-name/last-name (folded into the composite). The email is
|
||||||
|
# hidden only when it is folded into the cell (compact + include_email); with
|
||||||
|
# include_email off it stays offered as a separate column. Non-compact hides the
|
||||||
|
# composite in favour of the constituents.
|
||||||
|
defp name_variant_hidden?(@name_composite, compact_member, _include_email),
|
||||||
|
do: not compact_member
|
||||||
|
|
||||||
|
defp name_variant_hidden?(:email, compact_member, include_email),
|
||||||
|
do: compact_member and include_email
|
||||||
|
|
||||||
|
defp name_variant_hidden?(field, compact_member, _include_email)
|
||||||
|
when field in [:first_name, :last_name],
|
||||||
|
do: compact_member
|
||||||
|
|
||||||
|
defp name_variant_hidden?(_field, _compact_member, _include_email), do: false
|
||||||
|
|
||||||
|
defp address_variant_hidden?(@address_composite, compact_address),
|
||||||
|
do: not compact_address
|
||||||
|
|
||||||
|
defp address_variant_hidden?(field, compact_address)
|
||||||
|
when field in [:street, :house_number, :postal_code, :city],
|
||||||
|
do: compact_address
|
||||||
|
|
||||||
|
defp address_variant_hidden?(_field, _compact_address), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
All fields the column manager offers for the given view settings: the offered
|
||||||
|
member fields (see `offered_member_fields/3`) followed by the custom fields.
|
||||||
|
"""
|
||||||
|
@spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [
|
||||||
|
atom() | String.t()
|
||||||
|
]
|
||||||
|
def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do
|
||||||
|
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||||
|
offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names
|
||||||
|
end
|
||||||
|
|
||||||
defp overview_member_fields do
|
defp overview_member_fields do
|
||||||
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets all available fields for selection.
|
Builds field selection from URL, scoped to the columns offered for the given
|
||||||
|
view settings (`:compact_member` / `:member_include_email` / `:compact_address`
|
||||||
|
in `opts`): fields in `url_selection` are visible, all others false.
|
||||||
|
|
||||||
Returns a list of field identifiers:
|
Use when `?fields=...` is in the URL so column visibility is not merged with
|
||||||
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
global settings.
|
||||||
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `custom_fields` - List of CustomField resources that are available
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of field identifiers (atoms and strings)
|
|
||||||
"""
|
"""
|
||||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
|
||||||
def get_all_available_fields(custom_fields) do
|
|
||||||
member_fields =
|
|
||||||
overview_member_fields()
|
|
||||||
|> Enum.reject(fn field -> field == @export_only_alias end)
|
|
||||||
|
|
||||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
|
||||||
|
|
||||||
member_fields ++ custom_field_names
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
|
|
||||||
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
|
|
||||||
"""
|
|
||||||
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
|
|
||||||
String.t() => boolean()
|
String.t() => boolean()
|
||||||
}
|
}
|
||||||
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
def selection_from_url_only(url_selection, custom_fields, opts)
|
||||||
all_fields = get_all_available_fields(custom_fields)
|
when is_map(url_selection) and is_list(opts) do
|
||||||
|
{cm, ie, ca} = view_opts(opts)
|
||||||
|
do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
|
||||||
|
end
|
||||||
|
|
||||||
|
def selection_from_url_only(_, _, _), do: %{}
|
||||||
|
|
||||||
|
defp do_selection_from_url_only(url_selection, all_fields) do
|
||||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
field_string = field_to_string(field)
|
field_string = field_to_string(field)
|
||||||
visible = Map.get(url_selection, field_string, false)
|
visible = Map.get(url_selection, field_string, false)
|
||||||
|
|
@ -81,40 +213,38 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def selection_from_url_only(_, _), do: %{}
|
defp view_opts(opts) do
|
||||||
|
{
|
||||||
|
Keyword.get(opts, :compact_member, true),
|
||||||
|
Keyword.get(opts, :member_include_email, false),
|
||||||
|
Keyword.get(opts, :compact_address, true)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Merges user field selection with global settings.
|
Merges user field selection with global settings, scoped to the columns offered
|
||||||
|
for the given view settings (`:compact_member` / `:member_include_email` /
|
||||||
|
`:compact_address` in `opts`).
|
||||||
|
|
||||||
User selection takes priority over global settings. If a field is not in the
|
User selection takes priority over global settings. If a field is not in the
|
||||||
user selection, the global setting is used. If a field is not in global settings,
|
user selection, the global setting is used; if a field is not in global
|
||||||
it defaults to `true` (visible).
|
settings, it defaults to visible. Only offered columns appear in the result, so
|
||||||
|
a column that is not applicable in the current mode (e.g. `:first_name` while
|
||||||
## Parameters
|
the composite "Name" is on) never leaks into the visible set. Defaults follow
|
||||||
|
the mode via `default_visible_set/3`.
|
||||||
- `user_selection` - Map of field names (strings) to boolean visibility
|
|
||||||
- `global_settings` - Settings struct with `member_field_visibility` field
|
|
||||||
- `custom_fields` - List of CustomField resources
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
Map of field names (strings) to boolean visibility values
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> user_selection = %{"first_name" => false}
|
|
||||||
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
|
|
||||||
iex> merge_with_global_settings(user_selection, settings, [])
|
|
||||||
%{"first_name" => false, "email" => true} # User selection overrides global
|
|
||||||
"""
|
"""
|
||||||
@spec merge_with_global_settings(
|
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
|
||||||
%{String.t() => boolean()},
|
%{String.t() => boolean()}
|
||||||
map(),
|
def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
|
||||||
[struct()]
|
when is_list(opts) do
|
||||||
) :: %{String.t() => boolean()}
|
{cm, ie, ca} = view_opts(opts)
|
||||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
all_fields = get_offered_fields(custom_fields, cm, ie, ca)
|
||||||
all_fields = get_all_available_fields(custom_fields)
|
default_set = default_visible_set(cm, ie, ca)
|
||||||
global_visibility = get_global_visibility_map(global_settings, custom_fields)
|
do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do
|
||||||
|
global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
|
||||||
|
|
||||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
field_string = field_to_string(field)
|
field_string = field_to_string(field)
|
||||||
|
|
@ -129,35 +259,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the list of visible fields from a field selection map.
|
|
||||||
|
|
||||||
Returns only fields where visibility is `true`.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `field_selection` - Map of field names to boolean visibility
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of field identifiers (atoms for member fields, strings for custom fields)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
|
|
||||||
iex> get_visible_fields(selection)
|
|
||||||
[:first_name, :street]
|
|
||||||
"""
|
|
||||||
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
|
|
||||||
def get_visible_fields(field_selection) when is_map(field_selection) do
|
|
||||||
field_selection
|
|
||||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
|
||||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_visible_fields(_), do: []
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets visible member fields from field selection.
|
Gets visible member fields from field selection.
|
||||||
|
|
||||||
|
|
@ -259,15 +360,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
def get_visible_custom_fields(_), do: []
|
def get_visible_custom_fields(_), do: []
|
||||||
|
|
||||||
# Gets global visibility map from settings
|
# Gets global visibility map from settings
|
||||||
defp get_global_visibility_map(settings, custom_fields) do
|
defp get_global_visibility_map(settings, custom_fields, default_set) do
|
||||||
member_visibility = get_member_field_visibility_from_settings(settings)
|
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
|
||||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||||
|
|
||||||
Map.merge(member_visibility, custom_field_visibility)
|
Map.merge(member_visibility, custom_field_visibility)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
||||||
defp get_member_field_visibility_from_settings(settings) do
|
defp get_member_field_visibility_from_settings(settings, default_set) do
|
||||||
visibility_config =
|
visibility_config =
|
||||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||||
|
|
||||||
|
|
@ -276,13 +377,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
domain_map =
|
domain_map =
|
||||||
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
||||||
field_string = Atom.to_string(field)
|
field_string = Atom.to_string(field)
|
||||||
default_visibility = if field == :exit_date, do: false, else: true
|
default_visibility = MapSet.member?(default_set, field)
|
||||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||||
Map.put(acc, field_string, show_in_overview)
|
Map.put(acc, field_string, show_in_overview)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
||||||
Map.put(acc, Atom.to_string(field), true)
|
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
- `query` - Ash query for members
|
- `query` - Ash query for members
|
||||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
|
|
||||||
- `today` - Optional date to use as reference (currently unused, kept for API compatibility)
|
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
|
|
||||||
|
|
@ -33,8 +31,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
||||||
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
||||||
"""
|
"""
|
||||||
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
|
@spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t()
|
||||||
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
|
def load_cycles_for_members(query) do
|
||||||
# Load membership_fee_type and cycles
|
# Load membership_fee_type and cycles
|
||||||
query
|
query
|
||||||
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
||||||
|
|
@ -158,30 +156,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Filters members by unpaid cycle status.
|
|
||||||
|
|
||||||
Returns members that have unpaid cycles in either the last completed cycle
|
|
||||||
or the current cycle, depending on `show_current`.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `members` - List of member structs with loaded cycles
|
|
||||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of members with unpaid cycles
|
|
||||||
|
|
||||||
## Deprecated
|
|
||||||
|
|
||||||
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
|
|
||||||
"""
|
|
||||||
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
|
|
||||||
def filter_unpaid_members(members, show_current \\ false) do
|
|
||||||
filter_members_by_cycle_status(members, :unpaid, show_current)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Private helper function to format status label
|
# Private helper function to format status label
|
||||||
defp format_status_label(:paid), do: gettext("Paid")
|
defp format_status_label(:paid), do: gettext("Paid")
|
||||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||||
474
lib/mv_web/live/member_live/index/overview_query.ex
Normal file
474
lib/mv_web/live/member_live/index/overview_query.ex
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
|
@moduledoc """
|
||||||
|
Builds the Ash query for the member overview from the LiveView's filter/sort
|
||||||
|
state, so every filter and sort resolves in PostgreSQL via the `:overview`
|
||||||
|
keyset-paginated read action.
|
||||||
|
|
||||||
|
All previously in-memory passes (cycle status, boolean/date custom fields,
|
||||||
|
group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker
|
||||||
|
is always appended to the sort so keyset pages never skip or duplicate rows.
|
||||||
|
|
||||||
|
`build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:`
|
||||||
|
options to `Ash.read/2`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
require Ash.Sort
|
||||||
|
|
||||||
|
@type opts :: %{optional(atom()) => term()}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the `:overview` query from the given filter/sort options.
|
||||||
|
|
||||||
|
Recognised keys (all optional):
|
||||||
|
|
||||||
|
* `:search` — full-text search string
|
||||||
|
* `:group_filters` / `:groups` — `%{group_id => :in | :not_in}` and the valid groups
|
||||||
|
* `:fee_type_filters` / `:fee_types` — `%{fee_type_id => :in | :not_in}` and valid fee types
|
||||||
|
* `:date_filters` — built-in join/exit date filter map (see `DateFilter`)
|
||||||
|
* `:sort_field` / `:sort_order` — sort key and direction
|
||||||
|
* `:today` — reference date for cycle math (defaults to `Date.utc_today/0`)
|
||||||
|
"""
|
||||||
|
@spec build(opts()) :: Ash.Query.t()
|
||||||
|
def build(opts \\ %{}) do
|
||||||
|
Member
|
||||||
|
|> Ash.Query.for_read(:overview)
|
||||||
|
|> apply_search(opts[:search])
|
||||||
|
|> apply_group_filters(opts[:group_filters], opts[:groups])
|
||||||
|
|> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types])
|
||||||
|
|> apply_boolean_custom_field_filters(
|
||||||
|
opts[:boolean_custom_field_filters],
|
||||||
|
opts[:boolean_custom_fields]
|
||||||
|
)
|
||||||
|
|> apply_date_filters(opts[:date_filters])
|
||||||
|
|> apply_as_of_date_filter(opts[:as_of_date])
|
||||||
|
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||||
|
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||||
|
|> apply_suspended_filter(opts[:suspended], payment_period(opts))
|
||||||
|
|> apply_sort(
|
||||||
|
opts[:sort_field],
|
||||||
|
opts[:sort_order],
|
||||||
|
opts[:custom_fields] || [],
|
||||||
|
payment_period(opts)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp apply_search(query, search) when is_binary(search),
|
||||||
|
do: Member.apply_overview_search(query, search)
|
||||||
|
|
||||||
|
defp apply_search(query, _), do: query
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
when group_filters in [nil, %{}],
|
||||||
|
do: query
|
||||||
|
|
||||||
|
defp apply_group_filters(query, group_filters, groups) do
|
||||||
|
valid_ids = valid_id_set(groups)
|
||||||
|
|
||||||
|
{in_filters, not_in_filters} =
|
||||||
|
group_filters
|
||||||
|
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||||||
|
|> Enum.split_with(fn {_, value} -> value == :in end)
|
||||||
|
|
||||||
|
query
|
||||||
|
|> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end))
|
||||||
|
|> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_group_in_filter(query, id_strs) do
|
||||||
|
case cast_uuids(id_strs) do
|
||||||
|
[] -> query
|
||||||
|
uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_group_not_in_filters(query, id_strs) do
|
||||||
|
Enum.reduce(id_strs, query, fn id_str, q ->
|
||||||
|
case Ecto.UUID.cast(id_str) do
|
||||||
|
{:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid)))
|
||||||
|
_ -> q
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fee-type filters (:in OR; :not_in AND)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp apply_fee_type_filters(query, fee_type_filters, _fee_types)
|
||||||
|
when fee_type_filters in [nil, %{}],
|
||||||
|
do: query
|
||||||
|
|
||||||
|
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
|
||||||
|
valid_ids = valid_id_set(fee_types)
|
||||||
|
|
||||||
|
{in_filters, not_in_filters} =
|
||||||
|
fee_type_filters
|
||||||
|
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||||||
|
|> Enum.split_with(fn {_, value} -> value == :in end)
|
||||||
|
|
||||||
|
in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end))
|
||||||
|
|
||||||
|
query =
|
||||||
|
if in_uuids == [] do
|
||||||
|
query
|
||||||
|
else
|
||||||
|
Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids))
|
||||||
|
end
|
||||||
|
|
||||||
|
Enum.reduce(not_in_filters, query, fn {id_str, _}, q ->
|
||||||
|
case Ecto.UUID.cast(id_str) do
|
||||||
|
{:ok, uuid} ->
|
||||||
|
Ash.Query.filter(
|
||||||
|
q,
|
||||||
|
expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
q
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Boolean custom-field filters (AND across selected fields)
|
||||||
|
#
|
||||||
|
# Mirrors the previous in-memory behaviour: a member matches a `{field => bool}`
|
||||||
|
# filter only if it has a stored value row for that field whose boolean value
|
||||||
|
# equals `bool`. A member with no value row is excluded. Uses a JSONB
|
||||||
|
# containment predicate (`value @> '{"value": <bool>}'`), which the GIN index on
|
||||||
|
# `custom_field_values.value` can serve.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp apply_boolean_custom_field_filters(query, filters, _boolean_fields)
|
||||||
|
when filters in [nil, %{}],
|
||||||
|
do: query
|
||||||
|
|
||||||
|
defp apply_boolean_custom_field_filters(query, filters, boolean_fields) do
|
||||||
|
valid_ids = valid_id_set(boolean_fields)
|
||||||
|
|
||||||
|
filters
|
||||||
|
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||||||
|
|> Enum.reduce(query, fn {id_str, bool}, q ->
|
||||||
|
case Ecto.UUID.cast(id_str) do
|
||||||
|
{:ok, uuid} -> apply_one_boolean_filter(q, uuid, bool)
|
||||||
|
_ -> q
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_one_boolean_filter(query, uuid, bool) when is_boolean(bool) do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(
|
||||||
|
exists(
|
||||||
|
custom_field_values,
|
||||||
|
custom_field_id == ^uuid and
|
||||||
|
fragment("? @> jsonb_build_object('value', ?::boolean)", value, ^bool)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payment aging filter (period-scoped unpaid-cycle count)
|
||||||
|
#
|
||||||
|
# `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in
|
||||||
|
# the active period; `{:has_unpaid, n}` keeps members with at least n such
|
||||||
|
# cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a
|
||||||
|
# correlated aggregate over `membership_fee_cycles`), so no member set is
|
||||||
|
# loaded into memory. Suspended/paid cycles are excluded by the calculation.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp payment_period(opts) do
|
||||||
|
case opts[:payment_period] do
|
||||||
|
%{from: _, to: _} = period -> period
|
||||||
|
_ -> %{from: nil, to: nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to})
|
||||||
|
when is_integer(n) and n > 0 do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Two-sided open-cycle range (§1.22/§3.14): keeps members whose in-period
|
||||||
|
# unpaid count is within [min, max]. `max` nil means unbounded above, so
|
||||||
|
# `{:unpaid_range, 1, nil}` is the default "has open" (>= 1). Both bounds push
|
||||||
|
# to SQL through the same `unpaid_cycle_count` calculation.
|
||||||
|
defp apply_payment_filter(query, {:unpaid_range, min, max}, %{from: from, to: to})
|
||||||
|
when is_integer(min) and min > 0 do
|
||||||
|
query
|
||||||
|
|> Ash.Query.filter(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^min)
|
||||||
|
|> apply_unpaid_upper_bound(max, from, to)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_payment_filter(query, _filter, _period), do: query
|
||||||
|
|
||||||
|
defp apply_unpaid_upper_bound(query, max, from, to) when is_integer(max) and max > 0 do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) <= ^max)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_unpaid_upper_bound(query, _max, _from, _to), do: query
|
||||||
|
|
||||||
|
# Suspended-status filter (§1.23): keeps members with at least one suspended
|
||||||
|
# cycle whose cycle_end falls in the active period. Independent of the unpaid
|
||||||
|
# count, which excludes suspended (§2.5). A correlated `exists` pushes to SQL;
|
||||||
|
# the period bounds are composed conditionally so an unbounded (nil) side adds
|
||||||
|
# no `cycle_end` comparison rather than comparing against a literal nil.
|
||||||
|
defp apply_suspended_filter(query, true, %{from: from, to: to}) do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(exists(membership_fee_cycles, ^suspended_period_predicate(from, to)))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_suspended_filter(query, _suspended, _period), do: query
|
||||||
|
|
||||||
|
defp suspended_period_predicate(nil, nil), do: expr(status == :suspended)
|
||||||
|
|
||||||
|
defp suspended_period_predicate(from, nil),
|
||||||
|
do: expr(status == :suspended and cycle_end >= ^from)
|
||||||
|
|
||||||
|
defp suspended_period_predicate(nil, to),
|
||||||
|
do: expr(status == :suspended and cycle_end <= ^to)
|
||||||
|
|
||||||
|
defp suspended_period_predicate(from, to),
|
||||||
|
do: expr(status == :suspended and cycle_end >= ^from and cycle_end <= ^to)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in date filters (join/exit)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp apply_date_filters(query, nil), do: query
|
||||||
|
|
||||||
|
defp apply_date_filters(query, filters) when is_map(filters),
|
||||||
|
do: DateFilter.apply_ash_filter(query, filters)
|
||||||
|
|
||||||
|
defp apply_custom_date_filters(query, filters, custom_fields)
|
||||||
|
when is_map(filters) and is_list(custom_fields),
|
||||||
|
do: DateFilter.apply_custom_date_ash_filter(query, filters, custom_fields)
|
||||||
|
|
||||||
|
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Point-in-time membership (as-of date): a member counts as a member on the
|
||||||
|
# reference date X iff it had joined by X and had not yet left as of X
|
||||||
|
# (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an
|
||||||
|
# exit_date strictly after X (or nil) still counts as a member on X.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp apply_as_of_date_filter(query, %Date{} = x) do
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_as_of_date_filter(query, _), do: query
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sort (always append the unique id tie-breaker for keyset stability)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp apply_sort(query, nil, _order, _custom_fields, _period),
|
||||||
|
do: Ash.Query.sort(query, id: :asc)
|
||||||
|
|
||||||
|
defp apply_sort(query, _field, nil, _custom_fields, _period),
|
||||||
|
do: Ash.Query.sort(query, id: :asc)
|
||||||
|
|
||||||
|
# Composite "Member" column: sort by last name then first name. The email that
|
||||||
|
# shares the cell is display-only and does not affect the sort key (§1.21).
|
||||||
|
defp apply_sort(query, field, order, _custom_fields, _period) when field in [:name, "name"] do
|
||||||
|
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Composite "Address" column: sort by city, then postal code, then street.
|
||||||
|
defp apply_sort(query, field, order, _custom_fields, _period)
|
||||||
|
when field in [:address, "address"] do
|
||||||
|
Ash.Query.sort(query, [
|
||||||
|
{:city, order},
|
||||||
|
{:postal_code, order},
|
||||||
|
{:street, order},
|
||||||
|
{:id, :asc}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fees (payment) column: sort DB-side by the period-scoped unpaid-cycle
|
||||||
|
# count (§1.27). The count calculation takes the active period as arguments so
|
||||||
|
# the sort key matches the displayed value; the id tie-breaker keeps keyset
|
||||||
|
# pages stable across equal counts.
|
||||||
|
defp apply_sort(query, field, order, _custom_fields, %{from: from, to: to})
|
||||||
|
when field in [:payment, "payment"] do
|
||||||
|
Ash.Query.sort(query, [
|
||||||
|
{Ash.Sort.expr_sort(unpaid_cycle_count(period_from: ^from, period_to: ^to), :integer),
|
||||||
|
order},
|
||||||
|
{:id, :asc}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_sort(query, field, order, custom_fields, _period) do
|
||||||
|
case sort_key(field) do
|
||||||
|
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
||||||
|
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sorts by a custom field's JSONB value DB-side. The value is read from the
|
||||||
|
# first matching `custom_field_values` row for the field, cast per the field's
|
||||||
|
# value type so integers sort numerically and dates chronologically. Empty
|
||||||
|
# strings and members without a value row sort last in both directions
|
||||||
|
# (NULLS LAST), mirroring the previous in-memory `CustomFieldSort` behaviour.
|
||||||
|
defp apply_custom_field_sort(query, field, order, custom_fields) do
|
||||||
|
with id_str when is_binary(id_str) <- custom_field_id(field),
|
||||||
|
%{} = cf <- Enum.find(custom_fields, &(to_string(&1.id) == id_str)) do
|
||||||
|
nils_order = if order == :desc, do: :desc_nils_last, else: :asc_nils_last
|
||||||
|
|
||||||
|
Ash.Query.sort(query, [
|
||||||
|
{custom_field_sort_calc(cf.value_type, cf.id), nils_order},
|
||||||
|
{:id, :asc}
|
||||||
|
])
|
||||||
|
else
|
||||||
|
_ -> Ash.Query.sort(query, id: :asc)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp custom_field_id(field) when is_atom(field), do: custom_field_id(Atom.to_string(field))
|
||||||
|
|
||||||
|
defp custom_field_id(field) when is_binary(field) do
|
||||||
|
case String.split(field, Mv.Constants.custom_field_prefix()) do
|
||||||
|
["", id_str] -> id_str
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp custom_field_id(_), do: nil
|
||||||
|
|
||||||
|
# Maps the field's value type to the first matching custom-field value's typed
|
||||||
|
# sort key (scoped to the field), backed by the `sort_*` calculations on
|
||||||
|
# `CustomFieldValue` (a raw fragment is not allowed as an aggregate `field:`).
|
||||||
|
defp custom_field_sort_calc(:integer, id),
|
||||||
|
do:
|
||||||
|
Ash.Sort.expr_sort(
|
||||||
|
first(custom_field_values,
|
||||||
|
field: :sort_numeric,
|
||||||
|
query: [filter: expr(custom_field_id == ^id)]
|
||||||
|
),
|
||||||
|
:decimal
|
||||||
|
)
|
||||||
|
|
||||||
|
defp custom_field_sort_calc(:date, id),
|
||||||
|
do:
|
||||||
|
Ash.Sort.expr_sort(
|
||||||
|
first(custom_field_values,
|
||||||
|
field: :sort_date,
|
||||||
|
query: [filter: expr(custom_field_id == ^id)]
|
||||||
|
),
|
||||||
|
:date
|
||||||
|
)
|
||||||
|
|
||||||
|
defp custom_field_sort_calc(:boolean, id),
|
||||||
|
do:
|
||||||
|
Ash.Sort.expr_sort(
|
||||||
|
first(custom_field_values,
|
||||||
|
field: :sort_boolean,
|
||||||
|
query: [filter: expr(custom_field_id == ^id)]
|
||||||
|
),
|
||||||
|
:boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
defp custom_field_sort_calc(_type, id),
|
||||||
|
do:
|
||||||
|
Ash.Sort.expr_sort(
|
||||||
|
first(custom_field_values,
|
||||||
|
field: :sort_text,
|
||||||
|
query: [filter: expr(custom_field_id == ^id)]
|
||||||
|
),
|
||||||
|
:string
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolves a sort field (atom or string) to a DB sort key, or nil if it is a
|
||||||
|
# computed field handled elsewhere / not sortable.
|
||||||
|
defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"],
|
||||||
|
do: "membership_fee_type.name"
|
||||||
|
|
||||||
|
defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name
|
||||||
|
|
||||||
|
defp sort_key(field) when is_atom(field) do
|
||||||
|
if field in member_sort_fields(), do: field, else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_key(field) when is_binary(field) do
|
||||||
|
allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1)
|
||||||
|
if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_key(_), do: nil
|
||||||
|
|
||||||
|
defp member_sort_fields do
|
||||||
|
Mv.Constants.member_fields() -- [:notes]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp valid_id_set(records) when is_list(records) do
|
||||||
|
records
|
||||||
|
|> Enum.map(&to_string(&1.id))
|
||||||
|
|> Enum.map(&normalize_uuid/1)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> MapSet.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_id_set(_), do: MapSet.new()
|
||||||
|
|
||||||
|
defp normalize_uuid(raw) when is_binary(raw) do
|
||||||
|
case Ecto.UUID.cast(String.trim(raw)) do
|
||||||
|
{:ok, uuid} -> to_string(uuid)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_uuid(_), do: nil
|
||||||
|
|
||||||
|
defp cast_uuids(id_strs) do
|
||||||
|
id_strs
|
||||||
|
|> Enum.map(&Ecto.UUID.cast/1)
|
||||||
|
|> Enum.filter(&match?({:ok, _}, &1))
|
||||||
|
|> Enum.map(fn {:ok, uuid} -> uuid end)
|
||||||
|
end
|
||||||
|
end
|
||||||
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
|
||||||
175
lib/mv_web/live/member_live/index/view_settings.ex
Normal file
175
lib/mv_web/live/member_live/index/view_settings.ex
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ViewSettings do
|
||||||
|
@moduledoc """
|
||||||
|
Resolves and persists the member-overview view settings per browser/device.
|
||||||
|
|
||||||
|
A view-settings value is a map of four keys:
|
||||||
|
|
||||||
|
* `:density` — `:compact` or `:comfortable` (the row-spacing / compact mode)
|
||||||
|
* `:compact_member` — composite "Member" cell (last+first name) vs separate
|
||||||
|
first-name/last-name columns
|
||||||
|
* `:member_include_email` — whether the email is surfaced (email line in the
|
||||||
|
composite cell, or a separate Email column when the member field is split)
|
||||||
|
* `:compact_address` — composite Address cell (street / postal+city) vs
|
||||||
|
separate street/postal-code/city columns
|
||||||
|
|
||||||
|
Persistence is per browser (not per account), resolved in priority order from
|
||||||
|
the LiveView connect params, the session, the request cookie, and finally the
|
||||||
|
global defaults (`defaults/0`). The chosen value is written to a long-lived
|
||||||
|
cookie by a small client-side listener; on the next connected mount the client
|
||||||
|
echoes the cookie back through the socket connect params (the connect-info map
|
||||||
|
on a live socket does not expose cookies), and on the disconnected render the
|
||||||
|
cookie is read directly from the request conn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.Cookie
|
||||||
|
|
||||||
|
@cookie_name "member_view_settings"
|
||||||
|
@session_key "member_view_settings"
|
||||||
|
@connect_param "view_settings"
|
||||||
|
@cookie_max_age 365 * 24 * 60 * 60
|
||||||
|
|
||||||
|
@defaults %{
|
||||||
|
density: :compact,
|
||||||
|
compact_member: true,
|
||||||
|
member_include_email: false,
|
||||||
|
compact_address: true
|
||||||
|
}
|
||||||
|
|
||||||
|
@densities [:compact, :comfortable]
|
||||||
|
@bool_keys [:compact_member, :member_include_email, :compact_address]
|
||||||
|
|
||||||
|
@type t :: %{
|
||||||
|
density: :compact | :comfortable,
|
||||||
|
compact_member: boolean(),
|
||||||
|
member_include_email: boolean(),
|
||||||
|
compact_address: boolean()
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc "The global default view settings used when nothing is stored."
|
||||||
|
@spec defaults() :: %{
|
||||||
|
density: :compact,
|
||||||
|
compact_member: true,
|
||||||
|
member_include_email: false,
|
||||||
|
compact_address: true
|
||||||
|
}
|
||||||
|
def defaults, do: @defaults
|
||||||
|
|
||||||
|
@doc "The cookie name the settings are persisted under (used by the client listener)."
|
||||||
|
@spec cookie_name() :: String.t()
|
||||||
|
def cookie_name, do: @cookie_name
|
||||||
|
|
||||||
|
@doc "The cookie max-age in seconds (365 days)."
|
||||||
|
@spec cookie_max_age() :: 31_536_000
|
||||||
|
def cookie_max_age, do: @cookie_max_age
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a raw map (string or atom keys, string/boolean values) into a validated
|
||||||
|
partial settings map with atom keys. Unknown keys and invalid values are
|
||||||
|
dropped, so a caller can safely `Map.merge/2` the result over another source.
|
||||||
|
"""
|
||||||
|
@spec parse(term()) :: %{optional(atom()) => term()}
|
||||||
|
def parse(raw) when is_map(raw) do
|
||||||
|
Enum.reduce(raw, %{}, fn {key, value}, acc ->
|
||||||
|
case parse_pair(to_string(key), value) do
|
||||||
|
{k, v} -> Map.put(acc, k, v)
|
||||||
|
:error -> acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse(_), do: %{}
|
||||||
|
|
||||||
|
defp parse_pair("density", value) do
|
||||||
|
case parse_density(value) do
|
||||||
|
nil -> :error
|
||||||
|
density -> {:density, density}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_pair(key, value)
|
||||||
|
when key in ~w(compact_member member_include_email compact_address) do
|
||||||
|
case parse_bool(value) do
|
||||||
|
nil -> :error
|
||||||
|
bool -> {String.to_existing_atom(key), bool}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_pair(_key, _value), do: :error
|
||||||
|
|
||||||
|
defp parse_density(value) when value in @densities, do: value
|
||||||
|
defp parse_density("compact"), do: :compact
|
||||||
|
defp parse_density("comfortable"), do: :comfortable
|
||||||
|
defp parse_density(_), do: nil
|
||||||
|
|
||||||
|
defp parse_bool(value) when is_boolean(value), do: value
|
||||||
|
defp parse_bool("true"), do: true
|
||||||
|
defp parse_bool("false"), do: false
|
||||||
|
defp parse_bool(_), do: nil
|
||||||
|
|
||||||
|
@doc "Reads partial settings from the LiveView session map."
|
||||||
|
@spec get_from_session(map()) :: %{optional(atom()) => term()}
|
||||||
|
def get_from_session(session) when is_map(session),
|
||||||
|
do: parse_json(Map.get(session, @session_key))
|
||||||
|
|
||||||
|
def get_from_session(_), do: %{}
|
||||||
|
|
||||||
|
@doc "Reads partial settings from the request cookie header of a connect-info conn."
|
||||||
|
@spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()}
|
||||||
|
def get_from_cookie(%Plug.Conn{} = conn) do
|
||||||
|
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||||
|
[cookie_header | _rest] ->
|
||||||
|
cookie_header
|
||||||
|
|> Cookie.parse_header()
|
||||||
|
|> Map.get(@cookie_name)
|
||||||
|
|> parse_json()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_from_cookie(_), do: %{}
|
||||||
|
|
||||||
|
@doc "Reads partial settings from the LiveView connect params (raw JSON string)."
|
||||||
|
@spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()}
|
||||||
|
def get_from_connect_params(params) when is_map(params),
|
||||||
|
do: parse_json(Map.get(params, @connect_param))
|
||||||
|
|
||||||
|
def get_from_connect_params(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Resolves the effective view settings for a mount, merging the sources by
|
||||||
|
priority (connect params > session > cookie), falling back per key to the
|
||||||
|
global defaults.
|
||||||
|
"""
|
||||||
|
@spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t()
|
||||||
|
def resolve(session, conn, connect_params \\ nil) do
|
||||||
|
@defaults
|
||||||
|
|> Map.merge(get_from_cookie(conn))
|
||||||
|
|> Map.merge(get_from_session(session))
|
||||||
|
|> Map.merge(get_from_connect_params(connect_params))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Serializes settings to a JSON string for the client-side cookie writer."
|
||||||
|
@spec to_json(map()) :: String.t()
|
||||||
|
def to_json(settings) when is_map(settings) do
|
||||||
|
settings
|
||||||
|
|> Map.take([:density | @bool_keys])
|
||||||
|
|> Map.new(fn
|
||||||
|
{:density, density} -> {:density, to_string(density)}
|
||||||
|
{key, value} -> {key, value}
|
||||||
|
end)
|
||||||
|
|> Jason.encode!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_json(nil), do: %{}
|
||||||
|
|
||||||
|
defp parse_json(json) when is_binary(json) do
|
||||||
|
case Jason.decode(json) do
|
||||||
|
{:ok, decoded} -> parse(decoded)
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_json(_), do: %{}
|
||||||
|
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)}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ defmodule MvWeb.Translations.MemberFields do
|
||||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||||
def label(:membership_fee_type), do: gettext("Fee Type")
|
def label(:membership_fee_type), do: gettext("Fee Type")
|
||||||
def label(:groups), do: gettext("Groups")
|
def label(:groups), do: gettext("Groups")
|
||||||
|
def label(:name), do: gettext("Name")
|
||||||
|
def label(:address), do: gettext("Address")
|
||||||
|
|
||||||
# Fallback for unknown fields
|
# Fallback for unknown fields
|
||||||
def label(field) do
|
def label(field) do
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -126,7 +104,9 @@ msgstr "Feld hinzufügen"
|
||||||
msgid "Add members"
|
msgid "Add members"
|
||||||
msgstr "Mitglieder hinzufügen"
|
msgstr "Mitglieder hinzufügen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr "Adresse"
|
msgstr "Adresse"
|
||||||
|
|
@ -147,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"
|
||||||
|
|
@ -192,8 +172,7 @@ msgid "An account with this email already exists. Please verify your password to
|
||||||
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
|
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen."
|
||||||
|
|
||||||
#: lib/mv_web/helpers/ash_error_helpers.ex
|
#: lib/mv_web/helpers/ash_error_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/helpers.ex
|
#: lib/mv_web/live/role_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An error occurred"
|
msgid "An error occurred"
|
||||||
|
|
@ -209,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"
|
||||||
|
|
@ -488,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"
|
||||||
|
|
@ -503,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"
|
||||||
|
|
@ -549,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"
|
||||||
|
|
@ -815,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"
|
||||||
|
|
@ -830,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"
|
||||||
|
|
@ -914,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"
|
||||||
|
|
@ -1295,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"
|
||||||
|
|
@ -1469,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"
|
||||||
|
|
@ -1484,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."
|
||||||
|
|
@ -1505,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
|
||||||
|
|
@ -1529,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"
|
||||||
|
|
@ -1656,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
|
||||||
|
|
@ -1771,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"
|
||||||
|
|
@ -1811,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"
|
||||||
|
|
@ -1904,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"
|
||||||
|
|
@ -2008,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:"
|
||||||
|
|
@ -2107,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"
|
||||||
|
|
@ -2153,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."
|
||||||
|
|
@ -2243,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"
|
||||||
|
|
@ -2288,14 +2186,12 @@ msgstr "Beitragseinstellungen"
|
||||||
msgid "Membership fee start"
|
msgid "Membership fee start"
|
||||||
msgstr "Beitragsbeginn"
|
msgstr "Beitragsbeginn"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee type deleted"
|
msgid "Membership fee type deleted"
|
||||||
msgstr "Mitgliedsbeitragsart gelöscht"
|
msgstr "Mitgliedsbeitragsart gelöscht"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee type not found"
|
msgid "Membership fee type not found"
|
||||||
msgstr "Mitgliedsbeitragsart nicht gefunden"
|
msgstr "Mitgliedsbeitragsart nicht gefunden"
|
||||||
|
|
@ -2350,12 +2246,14 @@ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
@ -2406,7 +2304,8 @@ msgstr "Neue*r Benutzer*in"
|
||||||
msgid "New amount"
|
msgid "New amount"
|
||||||
msgstr "Neuer Betrag"
|
msgstr "Neuer Betrag"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/components/core_components.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
|
||||||
|
|
@ -2428,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"
|
||||||
|
|
@ -2692,11 +2586,10 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Paid"
|
msgid "Paid"
|
||||||
msgstr "Bezahlt"
|
msgstr "Bezahlt"
|
||||||
|
|
@ -2728,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"
|
||||||
|
|
@ -2836,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"
|
||||||
|
|
@ -3260,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."
|
||||||
|
|
@ -3293,11 +3166,6 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr "In der Übersicht anzeigen"
|
msgstr "In der Übersicht anzeigen"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Show/Hide Columns"
|
|
||||||
msgstr "Spalten ein-/ausblenden"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
|
|
@ -3391,10 +3259,11 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr "Pausiert"
|
msgstr "Pausiert"
|
||||||
|
|
@ -3599,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"
|
||||||
|
|
@ -3699,11 +3568,10 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr "Unbezahlt"
|
msgstr "Unbezahlt"
|
||||||
|
|
@ -3904,7 +3772,8 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/components/core_components.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
|
||||||
|
|
@ -3956,8 +3825,7 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte
|
||||||
msgid "You do not have permission to %{action} members."
|
msgid "You do not have permission to %{action} members."
|
||||||
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
|
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen."
|
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen."
|
||||||
|
|
@ -3973,8 +3841,7 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
|
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
|
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
|
||||||
|
|
@ -4079,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"
|
||||||
|
|
@ -4129,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"
|
||||||
|
|
@ -4145,3 +4008,452 @@ msgstr "Öffnen"
|
||||||
msgctxt "status"
|
msgctxt "status"
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Offen"
|
msgstr "Offen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} member"
|
||||||
|
msgid_plural "%{count} members"
|
||||||
|
msgstr[0] "%{count} Mitglied"
|
||||||
|
msgstr[1] "%{count} Mitglieder"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/search_bar_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear search"
|
||||||
|
msgstr "Suche zurücksetzen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact address field"
|
||||||
|
msgstr "Kompaktes Adressfeld"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact mode"
|
||||||
|
msgstr "Kompakter Modus"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View settings"
|
||||||
|
msgstr "Ansichtseinstellungen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "include email"
|
||||||
|
msgstr "inklusive E-Mail"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Reset to default"
|
||||||
|
msgstr "Auf Standard zurücksetzen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Columns"
|
||||||
|
msgstr "Spalten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Loading more members …"
|
||||||
|
msgstr "Weitere Mitglieder werden geladen …"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View"
|
||||||
|
msgstr "Ansicht"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact Member field"
|
||||||
|
msgstr "Kompaktes Mitglied-Feld"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by city"
|
||||||
|
msgstr "Klicke, um nach Ort zu sortieren"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by last name"
|
||||||
|
msgstr "Klicke, um nach Nachname zu sortieren"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No address"
|
||||||
|
msgstr "Keine Adresse"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active / former"
|
||||||
|
msgstr "Aktiv / ehemalig"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group"
|
||||||
|
msgstr "Gruppe"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr "Zahlung"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{label} — open payment history"
|
||||||
|
msgstr "%{label} — Zahlungsverlauf öffnen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Aktiv"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Add filter"
|
||||||
|
msgstr "Filter hinzufügen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All (incl. former)"
|
||||||
|
msgstr "Alle (inkl. ehemalige)"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Available filters"
|
||||||
|
msgstr "Verfügbare Filter"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear all"
|
||||||
|
msgstr "Alle löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom fields"
|
||||||
|
msgstr "Eigene Felder"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Exited"
|
||||||
|
msgstr "Ausgetreten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee type: %{name}"
|
||||||
|
msgstr "Beitragsart: %{name}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee type: not %{name}"
|
||||||
|
msgstr "Beitragsart: nicht %{name}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Filter by …"
|
||||||
|
msgstr "Filtern nach …"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Former"
|
||||||
|
msgstr "Ehemalig"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group: %{name}"
|
||||||
|
msgstr "Gruppe: %{name}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group: not %{name}"
|
||||||
|
msgstr "Gruppe: nicht %{name}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Joined"
|
||||||
|
msgstr "Beigetreten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 30 days"
|
||||||
|
msgstr "Letzte 30 Tage"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Membership"
|
||||||
|
msgstr "Mitgliedschaft"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment status"
|
||||||
|
msgstr "Zahlungsstatus"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Quick"
|
||||||
|
msgstr "Schnellfilter"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Remove filter: %{label}"
|
||||||
|
msgstr "Filter entfernen: %{label}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This month"
|
||||||
|
msgstr "Dieser Monat"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This year"
|
||||||
|
msgstr "Dieses Jahr"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "≥ %{n} unpaid"
|
||||||
|
msgstr "≥ %{n} offen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} open"
|
||||||
|
msgstr "%{count} offen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All paid"
|
||||||
|
msgstr "Alle bezahlt"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "April"
|
||||||
|
msgstr "April"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "August"
|
||||||
|
msgstr "August"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "December"
|
||||||
|
msgstr "Dezember"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "February"
|
||||||
|
msgstr "Februar"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fees"
|
||||||
|
msgstr "Beiträge"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "January"
|
||||||
|
msgstr "Januar"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "July"
|
||||||
|
msgstr "Juli"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "June"
|
||||||
|
msgstr "Juni"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "March"
|
||||||
|
msgstr "März"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "November"
|
||||||
|
msgstr "November"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "October"
|
||||||
|
msgstr "Oktober"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "September"
|
||||||
|
msgstr "September"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by open payment cycles"
|
||||||
|
msgstr "Klicke, um nach offenen Beitragszyklen zu sortieren"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 12 months"
|
||||||
|
msgstr "Letzte 12 Monate"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 3 months"
|
||||||
|
msgstr "Letzte 3 Monate"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 7 days"
|
||||||
|
msgstr "Letzte 7 Tage"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This quarter"
|
||||||
|
msgstr "Dieses Quartal"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{min}–%{max} unpaid"
|
||||||
|
msgstr "%{min}–%{max} offen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{n} unpaid"
|
||||||
|
msgstr "%{n} offen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active on %{date}"
|
||||||
|
msgstr "Aktiv am %{date}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active on reference date"
|
||||||
|
msgstr "Aktiv am Stichtag"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Filter: %{field}"
|
||||||
|
msgstr "Filter: %{field}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open cycles"
|
||||||
|
msgstr "Offene Zyklen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Suspended fees"
|
||||||
|
msgstr "Ausgesetzte Beiträge"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "max"
|
||||||
|
msgstr "max"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "min"
|
||||||
|
msgstr "min"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom range"
|
||||||
|
msgstr "Eigener Zeitraum"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} more"
|
||||||
|
msgstr "%{count} weitere"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open contributions"
|
||||||
|
msgstr "offene Beiträge"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Contribution period"
|
||||||
|
msgstr "Beitragszeitraum"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles"
|
||||||
|
msgstr "Die Angaben beziehen sich auf den gefilterten Beitragszeitraum. Beiträge über alle offenen Zyklen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}"
|
||||||
|
msgstr "Die Angaben beziehen sich auf den gefilterten Beitragszeitraum. Beiträge im Zeitraum %{from}–%{to}"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Active only"
|
||||||
|
#~ msgstr "Nur aktive"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Apply"
|
||||||
|
#~ msgstr "Übernehmen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fees across all outstanding cycles"
|
||||||
|
#~ msgstr "Beiträge über alle offenen Zyklen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fees in period %{from}–%{to}"
|
||||||
|
#~ msgstr "Beiträge im Zeitraum %{from}–%{to}"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Former only"
|
||||||
|
#~ msgstr "Nur ehemalige"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fully paid"
|
||||||
|
#~ msgstr "Vollständig bezahlt"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Has unpaid"
|
||||||
|
#~ msgstr "Hat offene"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Has unpaid ≥ 1"
|
||||||
|
#~ msgstr "Offen ≥ 1"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Has unpaid ≥ 3"
|
||||||
|
#~ msgstr "Offen ≥ 3"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Period"
|
||||||
|
#~ msgstr "Zeitraum"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Period from"
|
||||||
|
#~ msgstr "Zeitraum von"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Period to"
|
||||||
|
#~ msgstr "Zeitraum bis"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Range"
|
||||||
|
#~ msgstr "Zeitraum"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "is"
|
||||||
|
#~ msgstr "ist"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "is not"
|
||||||
|
#~ msgstr "ist nicht"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -127,7 +105,9 @@ msgstr ""
|
||||||
msgid "Add members"
|
msgid "Add members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -148,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"
|
||||||
|
|
@ -209,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"
|
||||||
|
|
@ -488,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"
|
||||||
|
|
@ -503,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"
|
||||||
|
|
@ -549,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"
|
||||||
|
|
@ -815,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"
|
||||||
|
|
@ -830,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"
|
||||||
|
|
@ -914,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"
|
||||||
|
|
@ -1295,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"
|
||||||
|
|
@ -1469,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"
|
||||||
|
|
@ -1484,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."
|
||||||
|
|
@ -1505,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
|
||||||
|
|
@ -1529,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 ""
|
||||||
|
|
@ -1656,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
|
||||||
|
|
@ -1771,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"
|
||||||
|
|
@ -1811,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"
|
||||||
|
|
@ -1904,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"
|
||||||
|
|
@ -2008,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:"
|
||||||
|
|
@ -2107,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"
|
||||||
|
|
@ -2153,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."
|
||||||
|
|
@ -2243,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"
|
||||||
|
|
@ -2348,12 +2247,14 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2404,7 +2305,8 @@ msgstr ""
|
||||||
msgid "New amount"
|
msgid "New amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/components/core_components.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
|
||||||
|
|
@ -2426,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"
|
||||||
|
|
@ -2690,11 +2587,10 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Paid"
|
msgid "Paid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2726,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"
|
||||||
|
|
@ -2834,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"
|
||||||
|
|
@ -3258,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."
|
||||||
|
|
@ -3291,11 +3167,6 @@ msgstr ""
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Show/Hide Columns"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
|
|
@ -3389,10 +3260,11 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -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,11 +3569,10 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3901,7 +3772,8 @@ msgstr ""
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/components/core_components.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
|
||||||
|
|
@ -4074,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 ""
|
||||||
|
|
@ -4124,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"
|
||||||
|
|
@ -4140,3 +4008,377 @@ msgstr ""
|
||||||
msgctxt "status"
|
msgctxt "status"
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} member"
|
||||||
|
msgid_plural "%{count} members"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/search_bar_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact address field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "include email"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Reset to default"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Columns"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Loading more members …"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact Member field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by city"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by last name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No address"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active / former"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{label} — open payment history"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Add filter"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All (incl. former)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Available filters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear all"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom fields"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Exited"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee type: %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee type: not %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Filter by …"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Former"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group: %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group: not %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Joined"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 30 days"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Membership"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Quick"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Remove filter: %{label}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This month"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This year"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "≥ %{n} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All paid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "April"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "August"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "December"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "February"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fees"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "January"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "July"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "June"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "March"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "November"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "October"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "September"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by open payment cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 12 months"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 3 months"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 7 days"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This quarter"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{min}–%{max} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{n} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active on %{date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active on reference date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Filter: %{field}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Suspended fees"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "max"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom range"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} more"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open contributions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Contribution period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -127,7 +105,9 @@ msgstr ""
|
||||||
msgid "Add members"
|
msgid "Add members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -148,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"
|
||||||
|
|
@ -193,8 +173,7 @@ msgid "An account with this email already exists. Please verify your password to
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/ash_error_helpers.ex
|
#: lib/mv_web/helpers/ash_error_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/helpers.ex
|
#: lib/mv_web/live/role_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An error occurred"
|
msgid "An error occurred"
|
||||||
|
|
@ -210,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"
|
||||||
|
|
@ -489,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"
|
||||||
|
|
@ -504,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"
|
||||||
|
|
@ -550,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"
|
||||||
|
|
@ -816,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"
|
||||||
|
|
@ -831,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"
|
||||||
|
|
@ -915,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"
|
||||||
|
|
@ -1296,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"
|
||||||
|
|
@ -1470,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"
|
||||||
|
|
@ -1485,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."
|
||||||
|
|
@ -1506,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
|
||||||
|
|
@ -1530,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 ""
|
||||||
|
|
@ -1657,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
|
||||||
|
|
@ -1772,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"
|
||||||
|
|
@ -1812,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"
|
||||||
|
|
@ -1905,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"
|
||||||
|
|
@ -2009,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:"
|
||||||
|
|
@ -2108,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"
|
||||||
|
|
@ -2154,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."
|
||||||
|
|
@ -2244,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"
|
||||||
|
|
@ -2289,14 +2187,12 @@ msgstr ""
|
||||||
msgid "Membership fee start"
|
msgid "Membership fee start"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership fee type deleted"
|
msgid "Membership fee type deleted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership fee type not found"
|
msgid "Membership fee type not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2351,12 +2247,14 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2407,7 +2305,8 @@ msgstr ""
|
||||||
msgid "New amount"
|
msgid "New amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/components/core_components.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
|
||||||
|
|
@ -2429,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"
|
||||||
|
|
@ -2693,11 +2587,10 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Paid"
|
msgid "Paid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2729,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"
|
||||||
|
|
@ -2837,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"
|
||||||
|
|
@ -3261,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."
|
||||||
|
|
@ -3294,11 +3167,6 @@ msgstr ""
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Show/Hide Columns"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
|
|
@ -3392,10 +3260,11 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3600,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 ""
|
||||||
|
|
@ -3700,11 +3569,10 @@ 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/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
|
||||||
#: lib/mv_web/live/statistics_live.ex
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3904,7 +3772,8 @@ msgstr ""
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
msgid "Yearly Interval - Joining Cycle Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/components/core_components.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
|
||||||
|
|
@ -3956,8 +3825,7 @@ msgstr ""
|
||||||
msgid "You do not have permission to %{action} members."
|
msgid "You do not have permission to %{action} members."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3973,8 +3841,7 @@ msgstr ""
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4079,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 ""
|
||||||
|
|
@ -4129,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"
|
||||||
|
|
@ -4145,3 +4008,487 @@ msgstr "Open"
|
||||||
msgctxt "status"
|
msgctxt "status"
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Open"
|
msgstr "Open"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} member"
|
||||||
|
msgid_plural "%{count} members"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/search_bar_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact address field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Compact mode"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "include email"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Reset to default"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Columns"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Loading more members …"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/view_settings_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Compact Member field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by city"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to sort by last name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No address"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active / former"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{label} — open payment history"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Add filter"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All (incl. former)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Available filters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear all"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom fields"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Exited"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee type: %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee type: not %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Filter by …"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Former"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group: %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Group: not %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Joined"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 30 days"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Membership"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Payment status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Quick"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Remove filter: %{label}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This month"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This year"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "≥ %{n} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "%{count} open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "All paid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "April"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "August"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "December"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "February"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fees"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "January"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "July"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "June"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "March"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "November"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "October"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "September"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Click to sort by open payment cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 12 months"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last 3 months"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Last 7 days"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This quarter"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{min}–%{max} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "%{n} unpaid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active on %{date}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Active on reference date"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Filter: %{field}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Open cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Suspended fees"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "max"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "min"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Custom range"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "%{count} more"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open contributions"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Contribution period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Active only"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Apply"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Click for member details"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fees across all outstanding cycles"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fees in period %{from}–%{to}"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fees · %{range}"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Former only"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Fully paid"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Has unpaid"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Has unpaid ≥ 1"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Has unpaid ≥ 3"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Last year"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "No open cycles for this period"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Payment · %{range}"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Period"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Period from"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Period to"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Range"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Suspended: %{date}"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Unpaid until %{date}"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "is"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "is not"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddCycleEndToMembershipFeeCycles do
|
||||||
|
@moduledoc """
|
||||||
|
Adds the denormalized `cycle_end` column to `membership_fee_cycles`.
|
||||||
|
|
||||||
|
The column is added nullable, backfilled from `cycle_start + interval` for every
|
||||||
|
existing row (via `Mv.MembershipFees.CycleEndBackfill`), and only then made
|
||||||
|
`NOT NULL`. Going forward the column is populated at create time by the
|
||||||
|
`SetCycleEnd` change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:membership_fee_cycles) do
|
||||||
|
add :cycle_end, :date
|
||||||
|
end
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
|
{:ok, _count} = Mv.MembershipFees.CycleEndBackfill.run()
|
||||||
|
|
||||||
|
alter table(:membership_fee_cycles) do
|
||||||
|
modify :cycle_end, :date, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:membership_fee_cycles) do
|
||||||
|
remove :cycle_end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddCustomFieldValuesValueGinIndex do
|
||||||
|
@moduledoc """
|
||||||
|
Adds a GIN index on `custom_field_values.value` (JSONB) to serve the boolean
|
||||||
|
custom-field containment predicates used by the member overview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create index(:custom_field_values, ["value"],
|
||||||
|
name: "custom_field_values_value_gin_index",
|
||||||
|
using: "gin"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists index(:custom_field_values, ["value"],
|
||||||
|
name: "custom_field_values_value_gin_index"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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,148 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "value",
|
||||||
|
"type": "map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"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": "custom_field_values_member_id_fkey",
|
||||||
|
"on_delete": "delete",
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "members"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "member_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"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": "custom_field_values_custom_field_id_fkey",
|
||||||
|
"on_delete": "delete",
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "custom_fields"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "custom_field_id",
|
||||||
|
"type": "uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"error_fields": [
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"value": "value"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"include": null,
|
||||||
|
"message": null,
|
||||||
|
"name": "custom_field_values_value_gin_index",
|
||||||
|
"nulls_distinct": true,
|
||||||
|
"prefix": null,
|
||||||
|
"table": null,
|
||||||
|
"unique": false,
|
||||||
|
"using": "gin",
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "6B19604F0AA26F503094E6AF5086FDAE0D054096B24099376EFF4BBCFF7E3CB5",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "custom_field_values_unique_custom_field_per_member_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "member_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "custom_field_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_custom_field_per_member",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "custom_field_values"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
{
|
||||||
|
"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": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "455B6D49C804301DF13F950318644BF28B491535A94F4ABF7805AA1A53287401",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
98
test/membership_fees/cycle_end_test.exs
Normal file
98
test/membership_fees/cycle_end_test.exs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
defmodule Mv.MembershipFees.CycleEndTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the denormalized `cycle_end` column on membership fee cycles:
|
||||||
|
the create-time change that sets it, and the one-time backfill helper.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2]
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
alias Mv.MembershipFees.CycleEndBackfill
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_member(fee_type, actor) do
|
||||||
|
member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create sets cycle_end" do
|
||||||
|
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
|
||||||
|
test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do
|
||||||
|
interval = unquote(interval)
|
||||||
|
fee_type = create_fee_type(%{interval: interval}, actor)
|
||||||
|
member = create_member(fee_type, actor)
|
||||||
|
cycle_start = ~D[2024-02-01]
|
||||||
|
|
||||||
|
cycle =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
amount: Decimal.new("50.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
|
assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create with interval argument" do
|
||||||
|
test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do
|
||||||
|
# Fee type carries :yearly, but the caller passes :monthly explicitly.
|
||||||
|
# If the interval argument is honored (no per-row fee-type lookup), the
|
||||||
|
# stored cycle_end reflects the argument, not the fee type's interval.
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
member = create_member(fee_type, actor)
|
||||||
|
cycle_start = ~D[2024-02-01]
|
||||||
|
|
||||||
|
cycle =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
cycle_start: cycle_start,
|
||||||
|
amount: Decimal.new("50.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
|
assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "backfill" do
|
||||||
|
test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do
|
||||||
|
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||||
|
member = create_member(fee_type, actor)
|
||||||
|
|
||||||
|
cycle =
|
||||||
|
MembershipFeeCycle
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
cycle_start: ~D[2024-04-01],
|
||||||
|
amount: Decimal.new("50.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
|
# Simulate a pre-existing row that predates the cycle_end column.
|
||||||
|
Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL")
|
||||||
|
|
||||||
|
Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [
|
||||||
|
Ecto.UUID.dump!(cycle.id)
|
||||||
|
])
|
||||||
|
|
||||||
|
assert {:ok, count} = CycleEndBackfill.run()
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor)
|
||||||
|
assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -2,10 +2,28 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
# The curated default columns hide the individual name/address sort headers,
|
||||||
|
# so these component tests make every sortable field visible via ?fields=.
|
||||||
|
# The individual name/address constituents are only *offered* when the compact
|
||||||
|
# Member / Address view settings are off, so the session opts them out of the
|
||||||
|
# composite columns before selecting the fields via ?fields=.
|
||||||
|
@cols "first_name,email,street,house_number,postal_code,city,country,join_date"
|
||||||
|
|
||||||
|
# Disables the compact Member/Address composites so first_name/last_name/email
|
||||||
|
# and street/house_number/postal_code/city are offered as individual sortable
|
||||||
|
# columns (persisted per browser via the view-settings session key).
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
describe "rendering" do
|
describe "rendering" do
|
||||||
test "renders with correct attributes", %{conn: conn} do
|
test "renders with correct attributes", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Test that the component renders with correct attributes
|
# Test that the component renders with correct attributes
|
||||||
assert has_element?(view, "[data-testid='first_name']")
|
assert has_element?(view, "[data-testid='first_name']")
|
||||||
|
|
@ -14,8 +32,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders all sortable headers", %{conn: conn} do
|
test "renders all sortable headers", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
sortable_fields = [
|
sortable_fields = [
|
||||||
:first_name,
|
:first_name,
|
||||||
|
|
@ -34,8 +52,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders correct labels", %{conn: conn} do
|
test "renders correct labels", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Test specific labels
|
# Test specific labels
|
||||||
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
|
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
|
||||||
|
|
@ -46,8 +64,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "sort icons" do
|
describe "sort icons" do
|
||||||
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# The neutral icon has the opcity class we can test for
|
# The neutral icon has the opcity class we can test for
|
||||||
# Test that EMAIL field specifically shows neutral icon
|
# Test that EMAIL field specifically shows neutral icon
|
||||||
|
|
@ -58,8 +76,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
|
|
||||||
|
{:ok, view, html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
||||||
|
|
||||||
# Test that FIRST_NAME field specifically shows ascending icon
|
# Test that FIRST_NAME field specifically shows ascending icon
|
||||||
# Test CSS classes - no opacity for active state
|
# Test CSS classes - no opacity for active state
|
||||||
|
|
@ -79,8 +99,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
|
||||||
|
{:ok, _view, html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Count occurrences to ensure only one descending sort icon. Dropdown
|
# Count occurrences to ensure only one descending sort icon. Dropdown
|
||||||
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
|
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
|
||||||
|
|
@ -91,8 +113,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple fields can have different icon states", %{conn: conn} do
|
test "multiple fields can have different icon states", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
||||||
|
|
||||||
# CITY field should be active (ascending)
|
# CITY field should be active (ascending)
|
||||||
refute has_element?(view, "[data-testid='city'] .opacity-40")
|
refute has_element?(view, "[data-testid='city'] .opacity-40")
|
||||||
|
|
@ -108,8 +132,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Start: all fields neutral except first name as default
|
# Start: all fields neutral except first name as default
|
||||||
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
||||||
|
|
@ -136,27 +160,31 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
# Test EMAIL field specifically
|
# Test EMAIL field specifically
|
||||||
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
|
{:ok, view, html_asc} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc")
|
||||||
|
|
||||||
assert html_asc =~ "hero-chevron-up"
|
assert html_asc =~ "hero-chevron-up"
|
||||||
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
|
|
||||||
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
|
{:ok, view, html_desc} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
assert html_desc =~ "hero-chevron-down"
|
assert html_desc =~ "hero-chevron-down"
|
||||||
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
refute has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
|
|
||||||
{:ok, view, html_neutral} = live(conn, "/members")
|
{:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
||||||
assert html_neutral =~ "hero-chevron-up-down"
|
assert html_neutral =~ "hero-chevron-up-down"
|
||||||
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
# Test neutral state - only one field should have active sort icon
|
# Test neutral state - only one field should have active sort icon
|
||||||
{:ok, _view, html_neutral} = live(conn, "/members")
|
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Count active icons (should be exactly 1 - ascending for default sort field)
|
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||||
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||||
|
|
@ -166,7 +194,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||||
|
|
||||||
# Test descending state
|
# Test descending state
|
||||||
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
{:ok, _view, html_desc} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
|
||||||
|
|
||||||
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||||
down_count = active_sort_down_count(html_desc)
|
down_count = active_sort_down_count(html_desc)
|
||||||
|
|
@ -189,32 +218,38 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "accessibility" do
|
describe "accessibility" do
|
||||||
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Check aria-label for unsorted state
|
# Check aria-label for unsorted state
|
||||||
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
|
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
||||||
|
|
||||||
# Check aria-label for ascending sort
|
# Check aria-label for ascending sort
|
||||||
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
|
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
|
||||||
|
|
||||||
# Check aria-label for descending sort
|
# Check aria-label for descending sort
|
||||||
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
|
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "includes tooltip with correct aria-label", %{conn: conn} do
|
test "includes tooltip with correct aria-label", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
||||||
|
|
||||||
# Check that tooltip div exists with correct data-tip
|
# Check that tooltip div exists with correct data-tip
|
||||||
assert has_element?(view, "[data-testid='first_name']")
|
assert has_element?(view, "[data-testid='first_name']")
|
||||||
|
|
@ -222,8 +257,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "aria-labels work for all sortable fields", %{conn: conn} do
|
test "aria-labels work for all sortable fields", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Test aria-labels for different fields
|
# Test aria-labels for different fields
|
||||||
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
|
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
|
||||||
|
|
@ -239,8 +274,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "component behavior" do
|
describe "component behavior" do
|
||||||
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Click on the first name sort header
|
# Click on the first name sort header
|
||||||
view
|
view
|
||||||
|
|
@ -252,8 +287,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "component handles different field types correctly", %{conn: conn} do
|
test "component handles different field types correctly", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Test that different field types render correctly
|
# Test that different field types render correctly
|
||||||
assert has_element?(view, "button[phx-value-field='first_name']")
|
assert has_element?(view, "button[phx-value-field='first_name']")
|
||||||
|
|
@ -264,8 +299,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "edge cases" do
|
describe "edge cases" do
|
||||||
test "handles invalid sort field gracefully", %{conn: conn} do
|
test "handles invalid sort field gracefully", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
|
|
||||||
|
{:ok, view, html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
|
||||||
|
|
||||||
# Should not crash and should default sorting for first name
|
# Should not crash and should default sorting for first name
|
||||||
assert html =~ "hero-chevron-up-down"
|
assert html =~ "hero-chevron-up-down"
|
||||||
|
|
@ -273,8 +310,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles invalid sort order gracefully", %{conn: conn} do
|
test "handles invalid sort order gracefully", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
|
|
||||||
|
{:ok, view, html} =
|
||||||
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
|
||||||
|
|
||||||
# Should default to ascending
|
# Should default to ascending
|
||||||
assert html =~ "hero-chevron-up"
|
assert html =~ "hero-chevron-up"
|
||||||
|
|
@ -282,8 +321,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles empty sort parameters", %{conn: conn} do
|
test "handles empty sort parameters", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
|
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
|
||||||
|
|
||||||
# Should show neutral icons
|
# Should show neutral icons
|
||||||
assert html =~ "hero-chevron-up-down"
|
assert html =~ "hero-chevron-up-down"
|
||||||
|
|
@ -293,8 +332,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "icon state transitions" do
|
describe "icon state transitions" do
|
||||||
test "icon changes when sorting state changes", %{conn: conn} do
|
test "icon changes when sorting state changes", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Start with neutral state
|
# Start with neutral state
|
||||||
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
assert has_element?(view, "[data-testid='city'] .opacity-40")
|
||||||
|
|
@ -309,8 +348,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple fields can be tested for icon states", %{conn: conn} do
|
test "multiple fields can be tested for icon states", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
|
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Email should be active (descending)
|
# Email should be active (descending)
|
||||||
assert html =~ "hero-chevron-down"
|
assert html =~ "hero-chevron-down"
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
22
test/mv_web/live/member_live/index/cookie_test.exs
Normal file
22
test/mv_web/live/member_live/index/cookie_test.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.CookieTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the shared cookie-header parser used by the overview persistence chain.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.Cookie
|
||||||
|
|
||||||
|
describe "parse_header/1" do
|
||||||
|
test "splits pairs and URL-decodes values" do
|
||||||
|
assert Cookie.parse_header("a=1; b=hello%20world") == %{"a" => "1", "b" => "hello world"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "maps a valueless entry to an empty string" do
|
||||||
|
assert Cookie.parse_header("flag; a=1") == %{"flag" => "", "a" => "1"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps only the first '=' as the separator" do
|
||||||
|
assert Cookie.parse_header("token=a=b=c") == %{"token" => "a=b=c"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
90
test/mv_web/live/member_live/index/export_payload_test.exs
Normal file
90
test/mv_web/live/member_live/index/export_payload_test.exs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the member-overview export descriptor builder: the column order it
|
||||||
|
emits must mirror the rendered table (DB fields in constants order, fee
|
||||||
|
type/status after the fee start date, groups before custom fields), and the
|
||||||
|
filter/sort/selection state must be serialized faithfully.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.ExportPayload
|
||||||
|
|
||||||
|
defp custom_field(id), do: %{id: id}
|
||||||
|
|
||||||
|
describe "build/1" do
|
||||||
|
test "orders columns like the table: db fields, fee type/status, groups, custom fields" do
|
||||||
|
assigns = %{
|
||||||
|
selected_members: MapSet.new(["m1", "m2"]),
|
||||||
|
member_fields_visible_db: [:first_name, :membership_fee_start_date, :last_name],
|
||||||
|
member_fields_visible_computed: [:membership_fee_status],
|
||||||
|
member_fields_visible: [
|
||||||
|
:first_name,
|
||||||
|
:last_name,
|
||||||
|
:membership_fee_start_date,
|
||||||
|
:membership_fee_type,
|
||||||
|
:membership_fee_status,
|
||||||
|
:groups
|
||||||
|
],
|
||||||
|
visible_custom_field_ids: ["cf-a"],
|
||||||
|
all_custom_fields: [custom_field("cf-a"), custom_field("cf-b")],
|
||||||
|
query: "ada",
|
||||||
|
sort_field: :last_name,
|
||||||
|
sort_order: :desc,
|
||||||
|
show_current_cycle: true,
|
||||||
|
cycle_status_filter: :unpaid,
|
||||||
|
boolean_custom_field_filters: %{"cf-x" => true}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = ExportPayload.build(assigns)
|
||||||
|
|
||||||
|
# DB fields follow Mv.Constants.member_fields/0 order (first_name, last_name,
|
||||||
|
# then membership_fee_start_date), membership_fee_type + status are inserted
|
||||||
|
# right after the fee start date, groups precede the custom fields, and custom
|
||||||
|
# fields keep table order (only visible ones).
|
||||||
|
assert payload.column_order == [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"membership_fee_start_date",
|
||||||
|
"membership_fee_type",
|
||||||
|
"membership_fee_status",
|
||||||
|
"groups",
|
||||||
|
"cf-a"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert Enum.sort(payload.selected_ids) == ["m1", "m2"]
|
||||||
|
assert payload.custom_field_ids == ["cf-a"]
|
||||||
|
assert payload.query == "ada"
|
||||||
|
assert payload.sort_field == "last_name"
|
||||||
|
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.cycle_status_filter == nil
|
||||||
|
assert payload.boolean_filters == %{"cf-x" => true}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults optional state and omits groups/custom fields when not visible" do
|
||||||
|
assigns = %{
|
||||||
|
selected_members: MapSet.new(),
|
||||||
|
member_fields_visible_db: [:first_name],
|
||||||
|
member_fields_visible_computed: [],
|
||||||
|
member_fields_visible: [:first_name],
|
||||||
|
visible_custom_field_ids: [],
|
||||||
|
all_custom_fields: [custom_field("cf-a")]
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = ExportPayload.build(assigns)
|
||||||
|
|
||||||
|
assert payload.column_order == ["first_name"]
|
||||||
|
assert payload.selected_ids == []
|
||||||
|
assert payload.custom_field_ids == []
|
||||||
|
assert payload.query == nil
|
||||||
|
assert payload.sort_field == nil
|
||||||
|
assert payload.sort_order == nil
|
||||||
|
assert payload.show_current_cycle == true
|
||||||
|
assert payload.cycle_status_filter == nil
|
||||||
|
assert payload.boolean_filters == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,248 +6,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
# Mock custom field structs for testing
|
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
|
||||||
defp create_custom_field(id, name, show_in_overview \\ true) do
|
test "compact member + compact address: Name, E-Mail, Adresse lead" do
|
||||||
%{
|
result = FieldVisibility.offered_member_fields(true, false, true)
|
||||||
id: id,
|
|
||||||
name: name,
|
assert Enum.take(result, 3) == [:name, :email, :address]
|
||||||
show_in_overview: show_in_overview
|
# the composite constituents are not offered while their composite is on
|
||||||
}
|
refute :first_name in result
|
||||||
|
refute :street in result
|
||||||
|
# the Datenfelder-ordered rest trails the identity block
|
||||||
|
assert index_of(result, :address) < index_of(result, :join_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_all_available_fields/1" do
|
test "compact member with email folded in: no separate E-Mail column" do
|
||||||
test "returns member fields and custom fields" do
|
result = FieldVisibility.offered_member_fields(true, true, true)
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom Field 1"),
|
|
||||||
create_custom_field("cf2", "Custom Field 2")
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
assert Enum.take(result, 2) == [:name, :address]
|
||||||
|
|
||||||
# Should include all member fields
|
|
||||||
assert :first_name in result
|
|
||||||
assert :email in result
|
|
||||||
assert :street in result
|
|
||||||
|
|
||||||
# Should include custom fields as strings
|
|
||||||
assert "custom_field_cf1" in result
|
|
||||||
assert "custom_field_cf2" in result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles empty custom fields list" do
|
|
||||||
result = FieldVisibility.get_all_available_fields([])
|
|
||||||
|
|
||||||
# Should only have member fields
|
|
||||||
assert :first_name in result
|
|
||||||
assert :email in result
|
|
||||||
|
|
||||||
refute Enum.any?(result, fn field ->
|
|
||||||
is_binary(field) and String.starts_with?(field, "custom_field_")
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes all member fields from constants" do
|
|
||||||
custom_fields = []
|
|
||||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
|
||||||
|
|
||||||
member_fields = Mv.Constants.member_fields()
|
|
||||||
|
|
||||||
Enum.each(member_fields, fn field ->
|
|
||||||
assert field in result
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes pseudo member fields (membership_fee_status, membership_fee_type, groups)" do
|
|
||||||
result = FieldVisibility.get_all_available_fields([])
|
|
||||||
|
|
||||||
assert :membership_fee_status in result
|
|
||||||
assert :membership_fee_type in result
|
|
||||||
assert :groups in result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "merge_with_global_settings/3" do
|
|
||||||
test "user selection overrides global settings" do
|
|
||||||
user_selection = %{"first_name" => false}
|
|
||||||
settings = %{member_field_visibility: %{first_name: true, email: true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "falls back to global settings when user selection is empty" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{first_name: false, email: true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "defaults to true when field not in settings" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{first_name: false}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# first_name from settings
|
|
||||||
assert result["first_name"] == false
|
|
||||||
# email defaults to true (not in settings)
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles custom fields visibility" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{}}
|
|
||||||
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom 1", true),
|
|
||||||
create_custom_field("cf2", "Custom 2", false)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["custom_field_cf1"] == true
|
|
||||||
assert result["custom_field_cf2"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "user selection overrides custom field visibility" do
|
|
||||||
user_selection = %{"custom_field_cf1" => false}
|
|
||||||
settings = %{member_field_visibility: %{}}
|
|
||||||
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom 1", true)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["custom_field_cf1"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles string keys in settings (JSONB format)" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles mixed atom and string keys in settings" do
|
|
||||||
user_selection = %{}
|
|
||||||
# Use string keys only (as JSONB would return)
|
|
||||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles nil settings gracefully" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: nil}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# Should default all fields to true
|
|
||||||
assert result["first_name"] == true
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles missing member_field_visibility key" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# Should default all fields to true
|
|
||||||
assert result["first_name"] == true
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes all fields in result" do
|
|
||||||
user_selection = %{"first_name" => false}
|
|
||||||
settings = %{member_field_visibility: %{email: true}}
|
|
||||||
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom 1", true)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# Should include all member fields
|
|
||||||
member_fields = Mv.Constants.member_fields()
|
|
||||||
|
|
||||||
Enum.each(member_fields, fn field ->
|
|
||||||
assert Map.has_key?(result, Atom.to_string(field))
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Should include custom fields
|
|
||||||
assert Map.has_key?(result, "custom_field_cf1")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_visible_fields/1" do
|
|
||||||
test "returns only fields with true visibility" do
|
|
||||||
selection = %{
|
|
||||||
"first_name" => true,
|
|
||||||
"email" => false,
|
|
||||||
"street" => true,
|
|
||||||
"custom_field_123" => false
|
|
||||||
}
|
|
||||||
|
|
||||||
result = FieldVisibility.get_visible_fields(selection)
|
|
||||||
|
|
||||||
assert :first_name in result
|
|
||||||
assert :street in result
|
|
||||||
refute :email in result
|
refute :email in result
|
||||||
refute "custom_field_123" in result
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "converts member field strings to atoms" do
|
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
|
||||||
selection = %{"first_name" => true, "email" => true}
|
result = FieldVisibility.offered_member_fields(false, false, true)
|
||||||
|
|
||||||
result = FieldVisibility.get_visible_fields(selection)
|
# the reported bug: the composite address must not jump ahead of the
|
||||||
|
# expanded name constituents
|
||||||
assert :first_name in result
|
assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
|
||||||
assert :email in result
|
assert index_of(result, :address) < index_of(result, :join_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "keeps custom fields as strings" do
|
test "non-compact member + non-compact address: name, email, then the address block, then rest" do
|
||||||
selection = %{"custom_field_abc-123" => true}
|
result = FieldVisibility.offered_member_fields(false, false, false)
|
||||||
|
|
||||||
result = FieldVisibility.get_visible_fields(selection)
|
assert Enum.take(result, 7) == [
|
||||||
|
:first_name,
|
||||||
|
:last_name,
|
||||||
|
:email,
|
||||||
|
:street,
|
||||||
|
:house_number,
|
||||||
|
:postal_code,
|
||||||
|
:city
|
||||||
|
]
|
||||||
|
|
||||||
assert "custom_field_abc-123" in result
|
# join_date and later Datenfelder columns never precede the address block
|
||||||
end
|
assert index_of(result, :city) < index_of(result, :join_date)
|
||||||
|
refute :name in result
|
||||||
test "handles empty selection" do
|
refute :address in result
|
||||||
assert FieldVisibility.get_visible_fields(%{}) == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles all fields hidden" do
|
|
||||||
selection = %{"first_name" => false, "email" => false}
|
|
||||||
|
|
||||||
assert FieldVisibility.get_visible_fields(selection) == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles invalid input" do
|
|
||||||
assert FieldVisibility.get_visible_fields(nil) == []
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
|
||||||
|
|
||||||
describe "get_visible_member_fields/1" do
|
describe "get_visible_member_fields/1" do
|
||||||
test "returns only member fields that are visible" do
|
test "returns only member fields that are visible" do
|
||||||
selection = %{
|
selection = %{
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -26,35 +25,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "load_cycles_for_members/2" do
|
|
||||||
test "efficiently loads cycles for members" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
|
||||||
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
|
||||||
|
|
||||||
query =
|
|
||||||
Member
|
|
||||||
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|
|
||||||
|> MembershipFeeStatus.load_cycles_for_members()
|
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
members = Ash.read!(query, actor: system_actor)
|
|
||||||
|
|
||||||
assert length(members) == 2
|
|
||||||
|
|
||||||
# Verify cycles are loaded
|
|
||||||
member1_loaded = Enum.find(members, &(&1.id == member1.id))
|
|
||||||
member2_loaded = Enum.find(members, &(&1.id == member2.id))
|
|
||||||
|
|
||||||
assert member1_loaded.membership_fee_cycles != nil
|
|
||||||
assert member2_loaded.membership_fee_cycles != nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_cycle_status_for_member/2" do
|
describe "get_cycle_status_for_member/2" do
|
||||||
test "returns status of last completed cycle" do
|
test "returns status of last completed cycle" do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
@ -339,27 +309,5 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
assert length(filtered) == 1
|
assert length(filtered) == 1
|
||||||
assert List.first(filtered).id == member2.id
|
assert List.first(filtered).id == member2.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns all members when filter is nil" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
members =
|
|
||||||
[member1, member2]
|
|
||||||
|> Enum.map(fn m ->
|
|
||||||
m
|
|
||||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
|
||||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
|
||||||
end)
|
|
||||||
|
|
||||||
# filter_unpaid_members should still work for backwards compatibility
|
|
||||||
filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
|
|
||||||
|
|
||||||
# Both members have no cycles, so both should be filtered out
|
|
||||||
assert Enum.empty?(filtered)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
135
test/mv_web/live/member_live/index/overview_query_test.exs
Normal file
135
test/mv_web/live/member_live/index/overview_query_test.exs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.OverviewQueryTest do
|
||||||
|
@moduledoc """
|
||||||
|
Resource-level tests for the DB-backed overview query builder: keyset
|
||||||
|
pagination and the standard DB filters/sort pushed down from the LiveView.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures,
|
||||||
|
only: [member_fixture_with_actor: 2, create_fee_type: 2, group_fixture: 1]
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(results), do: Enum.map(results, & &1.id) |> MapSet.new()
|
||||||
|
|
||||||
|
defp read_all_pages(query, actor, limit) do
|
||||||
|
page = Ash.read!(query, page: [limit: limit], actor: actor)
|
||||||
|
collect_pages(page, [page.results])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_pages(%{more?: true} = page, acc) do
|
||||||
|
next = Ash.page!(page, :next)
|
||||||
|
collect_pages(next, [next.results | acc])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_pages(_page, acc), do: acc |> Enum.reverse() |> List.flatten()
|
||||||
|
|
||||||
|
describe "keyset pagination" do
|
||||||
|
test "concatenated pages cover the full set once with no duplicates", %{actor: actor} do
|
||||||
|
members =
|
||||||
|
for _ <- 1..5, do: member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
expected = ids(members)
|
||||||
|
|
||||||
|
query = OverviewQuery.build(%{sort_field: :inserted_at, sort_order: :asc})
|
||||||
|
all = read_all_pages(query, actor, 2)
|
||||||
|
|
||||||
|
assert ids(all) == expected
|
||||||
|
assert length(all) == length(Enum.uniq_by(all, & &1.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DB filters" do
|
||||||
|
test "search matches name DB-side", %{actor: actor} do
|
||||||
|
hit = member_fixture_with_actor(%{first_name: "Zorblax", last_name: "Quux"}, actor)
|
||||||
|
_miss = member_fixture_with_actor(%{first_name: "Otto", last_name: "Normal"}, actor)
|
||||||
|
|
||||||
|
results =
|
||||||
|
OverviewQuery.build(%{search: "Zorblax"})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
assert hit.id in Enum.map(results, & &1.id)
|
||||||
|
refute Enum.any?(results, &(&1.first_name == "Otto"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group :in and :not_in resolve DB-side", %{actor: actor} do
|
||||||
|
group = group_fixture(%{name: "Board #{System.unique_integer([:positive])}"})
|
||||||
|
in_member = member_fixture_with_actor(%{}, actor)
|
||||||
|
out_member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.create_member_group(%{member_id: in_member.id, group_id: group.id},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
opts = %{group_filters: %{to_string(group.id) => :in}, groups: [group]}
|
||||||
|
in_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||||
|
assert in_member.id in in_ids
|
||||||
|
refute out_member.id in in_ids
|
||||||
|
|
||||||
|
not_opts = %{group_filters: %{to_string(group.id) => :not_in}, groups: [group]}
|
||||||
|
out_ids = OverviewQuery.build(not_opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||||
|
refute in_member.id in out_ids
|
||||||
|
assert out_member.id in out_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fee-type :in resolves DB-side", %{actor: actor} do
|
||||||
|
ft = create_fee_type(%{}, actor)
|
||||||
|
with_ft = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||||
|
without_ft = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
opts = %{fee_type_filters: %{to_string(ft.id) => :in}, fee_types: [ft]}
|
||||||
|
result_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
assert with_ft.id in result_ids
|
||||||
|
refute without_ft.id in result_ids
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "group sort (DB aggregate)" do
|
||||||
|
test "sorts by first group name DB-side, members with groups before those without", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
g_a = group_fixture(%{name: "AAA #{System.unique_integer([:positive])}"})
|
||||||
|
g_z = group_fixture(%{name: "ZZZ #{System.unique_integer([:positive])}"})
|
||||||
|
|
||||||
|
m_a = member_fixture_with_actor(%{}, actor)
|
||||||
|
m_z = member_fixture_with_actor(%{}, actor)
|
||||||
|
m_none = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.create_member_group(%{member_id: m_a.id, group_id: g_a.id}, actor: actor)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.create_member_group(%{member_id: m_z.id, group_id: g_z.id}, actor: actor)
|
||||||
|
|
||||||
|
asc =
|
||||||
|
OverviewQuery.build(%{sort_field: :groups, sort_order: :asc})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|> Enum.filter(&(&1 in [m_a.id, m_z.id, m_none.id]))
|
||||||
|
|
||||||
|
# group members first (AAA before ZZZ), member without groups last
|
||||||
|
assert asc == [m_a.id, m_z.id, m_none.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sort" do
|
||||||
|
test "sort by last_name is deterministic with id tie-breaker", %{actor: actor} do
|
||||||
|
a = member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||||
|
b = member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||||
|
|
||||||
|
results =
|
||||||
|
OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
ordered = results |> Enum.filter(&(&1.id in [a.id, b.id])) |> Enum.map(& &1.id)
|
||||||
|
assert ordered == Enum.sort([a.id, b.id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
109
test/mv_web/live/member_live/index/view_settings_test.exs
Normal file
109
test/mv_web/live/member_live/index/view_settings_test.exs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ViewSettingsTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.20 / §1.5 — the member-overview view settings (density + the compact
|
||||||
|
member/address field toggles) and their per-browser persistence chain
|
||||||
|
(connect params > session > cookie > global default).
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.ViewSettings
|
||||||
|
|
||||||
|
test "defaults are all-compact with the email line off" do
|
||||||
|
assert ViewSettings.defaults() == %{
|
||||||
|
density: :compact,
|
||||||
|
compact_member: true,
|
||||||
|
member_include_email: false,
|
||||||
|
compact_address: true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "parse/1" do
|
||||||
|
test "reads a partial map with string keys and string/bool values" do
|
||||||
|
assert ViewSettings.parse(%{
|
||||||
|
"density" => "comfortable",
|
||||||
|
"compact_member" => false,
|
||||||
|
"member_include_email" => true
|
||||||
|
}) == %{
|
||||||
|
density: :comfortable,
|
||||||
|
compact_member: false,
|
||||||
|
member_include_email: true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores unknown keys and invalid values" do
|
||||||
|
assert ViewSettings.parse(%{"density" => "huge", "bogus" => true}) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty map for non-maps" do
|
||||||
|
assert ViewSettings.parse(nil) == %{}
|
||||||
|
assert ViewSettings.parse("x") == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_from_* sources" do
|
||||||
|
test "session JSON string" do
|
||||||
|
session = %{"member_view_settings" => ~s({"density":"comfortable"})}
|
||||||
|
assert ViewSettings.get_from_session(session) == %{density: :comfortable}
|
||||||
|
assert ViewSettings.get_from_session(%{}) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cookie header" do
|
||||||
|
json = ~s({"compact_address":false})
|
||||||
|
conn = %Plug.Conn{req_headers: [{"cookie", "member_view_settings=#{URI.encode(json)}"}]}
|
||||||
|
assert ViewSettings.get_from_cookie(conn) == %{compact_address: false}
|
||||||
|
assert ViewSettings.get_from_cookie(%Plug.Conn{req_headers: []}) == %{}
|
||||||
|
assert ViewSettings.get_from_cookie(nil) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "connect params (raw JSON string under view_settings)" do
|
||||||
|
params = %{"view_settings" => ~s({"density":"comfortable","compact_member":false})}
|
||||||
|
|
||||||
|
assert ViewSettings.get_from_connect_params(params) == %{
|
||||||
|
density: :comfortable,
|
||||||
|
compact_member: false
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ViewSettings.get_from_connect_params(nil) == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "resolve/3 priority connect_params > session > cookie > default" do
|
||||||
|
test "falls back to defaults when nothing is stored" do
|
||||||
|
assert ViewSettings.resolve(%{}, nil, nil) == ViewSettings.defaults()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cookie overrides default, session overrides cookie, connect params win" do
|
||||||
|
cookie_json = ~s({"density":"comfortable","compact_member":false,"compact_address":false})
|
||||||
|
|
||||||
|
conn = %Plug.Conn{
|
||||||
|
req_headers: [{"cookie", "member_view_settings=#{URI.encode(cookie_json)}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
session = %{"member_view_settings" => ~s({"compact_member":true})}
|
||||||
|
connect_params = %{"view_settings" => ~s({"density":"compact"})}
|
||||||
|
|
||||||
|
assert ViewSettings.resolve(session, conn, connect_params) == %{
|
||||||
|
# from connect params (highest)
|
||||||
|
density: :compact,
|
||||||
|
# from session (overrides cookie)
|
||||||
|
compact_member: true,
|
||||||
|
# from cookie
|
||||||
|
compact_address: false,
|
||||||
|
# default
|
||||||
|
member_include_email: false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_json/1 round-trips through parse/1" do
|
||||||
|
settings = %{
|
||||||
|
density: :comfortable,
|
||||||
|
compact_member: false,
|
||||||
|
member_include_email: true,
|
||||||
|
compact_address: false
|
||||||
|
}
|
||||||
|
|
||||||
|
assert settings |> ViewSettings.to_json() |> Jason.decode!() |> ViewSettings.parse() ==
|
||||||
|
settings
|
||||||
|
end
|
||||||
|
end
|
||||||
81
test/mv_web/member_live/index_a11y_hardening_test.exs
Normal file
81
test/mv_web/member_live/index_a11y_hardening_test.exs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexA11yHardeningTest do
|
||||||
|
@moduledoc """
|
||||||
|
§3.5 — WCAG 2.2 AA posture: ≥24px interactive targets in both densities,
|
||||||
|
scroll-margin on rows so a focused row is not obscured by the sticky header,
|
||||||
|
select-all `indeterminate` for partial selection, and reflow markup keeping the
|
||||||
|
table in its own focusable scroll region.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
@moduletag :ui
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
members =
|
||||||
|
for i <- 1..3 do
|
||||||
|
{:ok, m} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
m
|
||||||
|
end
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), members: members}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rows reserve scroll-margin under the sticky header", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
assert html =~ "scroll-mt-16"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Focusable region (1.4.10: table scrolls within its own focusable region).
|
||||||
|
assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/
|
||||||
|
# The toolbar reflows to one column on narrow viewports.
|
||||||
|
assert html =~ "flex-wrap"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# The row/select-all checkboxes are pinned to 1.5rem (24px) in both densities
|
||||||
|
# via the `#members-table-guard input.checkbox` CSS rule (WCAG 2.5.8), which
|
||||||
|
# overrides DaisyUI's density-proportional sizing. The faithful proxy in a
|
||||||
|
# LiveView test is that the guarded checkbox markup exists in both densities.
|
||||||
|
assert has_element?(view, "#members-table-guard input[type='checkbox'].checkbox")
|
||||||
|
|
||||||
|
# Switch to comfortable via the view-settings dropdown.
|
||||||
|
view |> element("[data-testid='view-settings-button']") |> render_click()
|
||||||
|
view |> element("[data-testid='view-setting-density']") |> render_click()
|
||||||
|
assert has_element?(view, "#members-table-guard input[type='checkbox'].checkbox")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select-all reflects indeterminate for a partial selection", %{
|
||||||
|
conn: conn,
|
||||||
|
members: members
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# No selection: not indeterminate.
|
||||||
|
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
|
||||||
|
|
||||||
|
# Select one of several -> partial -> indeterminate.
|
||||||
|
[m | _] = members
|
||||||
|
render_click(view, "select_member", %{"id" => m.id})
|
||||||
|
assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']")
|
||||||
|
|
||||||
|
# Select all -> fully checked, not indeterminate.
|
||||||
|
view |> element("[phx-click='select_all']") |> render_click()
|
||||||
|
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
|
||||||
|
assert has_element?(view, "#select-all-checkbox[checked]")
|
||||||
|
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
|
||||||
87
test/mv_web/member_live/index_address_cell_test.exs
Normal file
87
test/mv_web/member_live/index_address_cell_test.exs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexAddressCellTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.1 — The address renders as one composite cell: line 1 = street + house
|
||||||
|
number, line 2 = postal code + city.
|
||||||
|
§1.12 — City and postal code stay findable via search despite the composite
|
||||||
|
cell.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
defp create_member(attrs) do
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
Map.merge(
|
||||||
|
%{first_name: "Addr", last_name: "Tester", email: "addr@example.com"},
|
||||||
|
attrs
|
||||||
|
),
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
test "address shows as one cell with street/house on line 1 and postal/city on line 2", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
member =
|
||||||
|
create_member(%{
|
||||||
|
street: "Hauptstraße",
|
||||||
|
house_number: "12a",
|
||||||
|
postal_code: "10115",
|
||||||
|
city: "Berlin"
|
||||||
|
})
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
cell =
|
||||||
|
view
|
||||||
|
|> element("#row-#{member.id} [data-testid='member-address']")
|
||||||
|
|> render()
|
||||||
|
|
||||||
|
assert cell =~ "Hauptstraße"
|
||||||
|
assert cell =~ "12a"
|
||||||
|
assert cell =~ "10115"
|
||||||
|
assert cell =~ "Berlin"
|
||||||
|
|
||||||
|
# Composite, not four separate sortable address columns.
|
||||||
|
refute has_element?(view, "[data-testid='street']")
|
||||||
|
refute has_element?(view, "[data-testid='city']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member with no address parts renders a screen-reader label, not a blank cell", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
member = create_member(%{email: "no-address@example.com"})
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
row = view |> element("#row-#{member.id}") |> render()
|
||||||
|
|
||||||
|
assert row =~ "No address"
|
||||||
|
refute has_element?(view, "#row-#{member.id} [data-testid='member-address']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "members stay findable by city via search", %{conn: conn} do
|
||||||
|
member = create_member(%{city: "Hamburg", email: "city-search@example.com"})
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members?query=Hamburg")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{member.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "members stay findable by postal code via search", %{conn: conn} do
|
||||||
|
member = create_member(%{postal_code: "99999", email: "postal-search@example.com"})
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members?query=99999")
|
||||||
|
|
||||||
|
assert has_element?(view, "#row-#{member.id}")
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexBooleanCustomFieldFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.15 — DB-backed boolean custom-field filter (in/not_in) must match the
|
||||||
|
previous in-memory behaviour exactly, including a member with no stored value
|
||||||
|
row for the field.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
|
||||||
|
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
alias Mv.Membership.CustomFieldValue
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_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)
|
||||||
|
|
||||||
|
yes = member_fixture_with_actor(%{}, actor)
|
||||||
|
no = member_fixture_with_actor(%{}, actor)
|
||||||
|
none = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
set_bool(yes, field, true, actor)
|
||||||
|
set_bool(no, field, false, actor)
|
||||||
|
|
||||||
|
%{actor: actor, field: field, yes: yes, no: no, none: none}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_bool(member, field, bool, actor) do
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: field.id,
|
||||||
|
value: %{"_union_type" => "boolean", "_union_value" => bool}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp db_ids(filters, field, actor) do
|
||||||
|
OverviewQuery.build(%{
|
||||||
|
boolean_custom_field_filters: filters,
|
||||||
|
boolean_custom_fields: [field]
|
||||||
|
})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp oracle_ids(filters, field, actor) do
|
||||||
|
members =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.load(custom_field_values: [:custom_field])
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
Index.apply_boolean_custom_field_filters(members, filters, [field])
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter true matches only members with stored true value", ctx do
|
||||||
|
filters = %{to_string(ctx.field.id) => true}
|
||||||
|
assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor)
|
||||||
|
assert ctx.yes.id in db_ids(filters, ctx.field, ctx.actor)
|
||||||
|
refute ctx.no.id in db_ids(filters, ctx.field, ctx.actor)
|
||||||
|
refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter false matches only members with stored false value (not missing rows)", ctx do
|
||||||
|
filters = %{to_string(ctx.field.id) => false}
|
||||||
|
assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor)
|
||||||
|
assert ctx.no.id in db_ids(filters, ctx.field, ctx.actor)
|
||||||
|
refute ctx.yes.id in db_ids(filters, ctx.field, ctx.actor)
|
||||||
|
refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
71
test/mv_web/member_live/index_bulk_scope_test.exs
Normal file
71
test/mv_web/member_live/index_bulk_scope_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexBulkScopeTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.17 — Bulk "all" scope spans the whole filtered set (re-queried from the DB),
|
||||||
|
not only the loaded/visible page, and the count reflects the full filtered
|
||||||
|
total.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
# More than one keyset page so "loaded" and "all matching" differ.
|
||||||
|
@total 60
|
||||||
|
|
||||||
|
defp seed(n) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
Enum.each(1..n, fn i ->
|
||||||
|
idx = String.pad_leading(Integer.to_string(i), 3, "0")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Bulk#{idx}", last_name: "Scope", email: "bulk#{idx}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp scope_badge_text(view) do
|
||||||
|
view
|
||||||
|
|> render()
|
||||||
|
|> LazyHTML.from_fragment()
|
||||||
|
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||||
|
|> LazyHTML.text()
|
||||||
|
|> String.trim()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select all selects the whole filtered set, not just the loaded page", %{conn: conn} do
|
||||||
|
seed(@total)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("[phx-click='select_all']") |> render_click()
|
||||||
|
|
||||||
|
# The selection count badge reflects all matching members, not the 50 loaded.
|
||||||
|
assert scope_badge_text(view) == "#{@total}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "copy-emails with no selection copies every matching member's email", %{conn: conn} do
|
||||||
|
seed(@total)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
result = render_hook(view, "copy_emails", %{})
|
||||||
|
|
||||||
|
# All 60 matching members are copied, not only the 50 loaded rows.
|
||||||
|
assert result =~ "#{@total}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select-all then copy copies the whole filtered set", %{conn: conn} do
|
||||||
|
seed(@total)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("[phx-click='select_all']") |> render_click()
|
||||||
|
result = render_hook(view, "copy_emails", %{})
|
||||||
|
|
||||||
|
assert result =~ "#{@total}"
|
||||||
|
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
|
||||||
76
test/mv_web/member_live/index_column_manager_test.exs
Normal file
76
test/mv_web/member_live/index_column_manager_test.exs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexColumnManagerTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.6 — The column manager sits in the filter toolbar, offers a per-column
|
||||||
|
visibility toggle plus All and None controls styled as buttons (not links),
|
||||||
|
and applies changes while preserving the existing persistence chain.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Manager", last_name: "Columns", email: "manager@example.com"},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Both the add-filter builder trigger and the column-manager trigger render
|
||||||
|
# in the same toolbar.
|
||||||
|
assert has_element?(view, "[data-testid='add-filter-trigger']")
|
||||||
|
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
|
||||||
|
|
||||||
|
# All / None render as buttons with button styling.
|
||||||
|
assert has_element?(view, "button.btn[phx-click='select_all']", "All")
|
||||||
|
assert has_element?(view, "button.btn[phx-click='select_none']", "None")
|
||||||
|
refute has_element?(view, "a[phx-click='select_all']")
|
||||||
|
refute has_element?(view, "a[phx-click='select_none']")
|
||||||
|
|
||||||
|
# Per-column visibility toggles are accessible checkbox menu items.
|
||||||
|
assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
|
||||||
|
view |> element("button[phx-value-item='join_date']") |> render_click()
|
||||||
|
|
||||||
|
# The selection is pushed to the URL fields param so it survives reloads.
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "fields="
|
||||||
|
refute has_element?(view, "[data-testid='join_date']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a reset button restores the curated default column set", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
|
||||||
|
|
||||||
|
# Reset is a button styled like All/None, sitting next to them, with a tooltip.
|
||||||
|
assert has_element?(view, "button.btn[phx-click='reset_fields']")
|
||||||
|
|
||||||
|
# Hide a curated-default column, then reset restores it.
|
||||||
|
view |> element("button[phx-value-item='join_date']") |> render_click()
|
||||||
|
assert_patch(view)
|
||||||
|
refute has_element?(view, "[data-testid='join_date']")
|
||||||
|
|
||||||
|
view |> element("button[phx-click='reset_fields']") |> render_click()
|
||||||
|
assert_patch(view)
|
||||||
|
assert has_element?(view, "[data-testid='join_date']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -206,14 +206,20 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
live(
|
||||||
|
conn,
|
||||||
|
"/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc"
|
||||||
|
)
|
||||||
|
|
||||||
# Click on email column
|
# Click on email column
|
||||||
view
|
view
|
||||||
|> element("[data-testid='email']")
|
|> element("[data-testid='email']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
|
# The fields param rides along on the patch, so assert the sort outcome.
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "sort_field=email"
|
||||||
|
assert path =~ "sort_order=asc"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "clicking custom field column after regular column works", %{
|
test "clicking custom field column after regular column works", %{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDateCustomFieldFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.16 — DB-backed date custom-field range filter must match the previous
|
||||||
|
in-memory behaviour exactly, including inclusive boundaries and members with
|
||||||
|
no stored value row (excluded).
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
|
||||||
|
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
alias Mv.Membership.CustomFieldValue
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "birthday_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: :date,
|
||||||
|
show_in_overview: true
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
on_lower = member_with_date(field, ~D[2024-01-01], actor)
|
||||||
|
inside = member_with_date(field, ~D[2024-06-15], actor)
|
||||||
|
on_upper = member_with_date(field, ~D[2024-12-31], actor)
|
||||||
|
below = member_with_date(field, ~D[2023-12-31], actor)
|
||||||
|
above = member_with_date(field, ~D[2025-01-01], actor)
|
||||||
|
none = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
%{
|
||||||
|
actor: actor,
|
||||||
|
field: field,
|
||||||
|
on_lower: on_lower,
|
||||||
|
inside: inside,
|
||||||
|
on_upper: on_upper,
|
||||||
|
below: below,
|
||||||
|
above: above,
|
||||||
|
none: none
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_with_date(field, date, actor) do
|
||||||
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: field.id,
|
||||||
|
value: %{"_union_type" => "date", "_union_value" => date}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filters(field), do: %{to_string(field.id) => %{from: ~D[2024-01-01], to: ~D[2024-12-31]}}
|
||||||
|
|
||||||
|
defp db_ids(field, actor) do
|
||||||
|
OverviewQuery.build(%{date_filters: filters(field), date_custom_fields: [field]})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp oracle_ids(field, actor) do
|
||||||
|
members =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.load(custom_field_values: [:custom_field])
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
DateFilter.apply_in_memory(members, filters(field), [field])
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "inclusive range matches in-memory oracle, boundaries included", ctx do
|
||||||
|
db = db_ids(ctx.field, ctx.actor)
|
||||||
|
assert db == oracle_ids(ctx.field, ctx.actor)
|
||||||
|
|
||||||
|
assert ctx.on_lower.id in db
|
||||||
|
assert ctx.inside.id in db
|
||||||
|
assert ctx.on_upper.id in db
|
||||||
|
refute ctx.below.id in db
|
||||||
|
refute ctx.above.id in db
|
||||||
|
refute ctx.none.id in db
|
||||||
|
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
|
||||||
65
test/mv_web/member_live/index_default_columns_test.exs
Normal file
65
test/mv_web/member_live/index_default_columns_test.exs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.2 — With no persisted column selection, exactly the curated default columns
|
||||||
|
are visible: selection checkbox, Name (composite, no in-cell email line),
|
||||||
|
E-Mail (its own column while the compact Member field keeps the email line off),
|
||||||
|
Address (composite), fee type, fee status, groups, join date.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{
|
||||||
|
first_name: "Col",
|
||||||
|
last_name: "Default",
|
||||||
|
email: "col@example.com",
|
||||||
|
street: "Musterweg",
|
||||||
|
house_number: "1",
|
||||||
|
postal_code: "10115",
|
||||||
|
city: "Berlin"
|
||||||
|
},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "curated columns are visible by default", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Composite Name + Address cells.
|
||||||
|
assert has_element?(view, "[data-testid='member-name']")
|
||||||
|
assert has_element?(view, "[data-testid='member-address']")
|
||||||
|
# E-Mail is its own column by default (compact Member field, email line off).
|
||||||
|
assert has_element?(view, "[data-testid='email']")
|
||||||
|
# Fee type, fee status, groups, join date headers.
|
||||||
|
assert has_element?(view, "[data-testid='membership_fee_type']")
|
||||||
|
assert has_element?(view, "[data-testid='join_date']")
|
||||||
|
# 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']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# The composite Name/Address cells replace their constituents by default.
|
||||||
|
# E-Mail is excluded here: it is a default-visible column of its own.
|
||||||
|
for field <- ~w(first_name last_name city street house_number postal_code country) do
|
||||||
|
refute has_element?(view, "[data-testid='#{field}']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an explicit field selection still overrides the curated default", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members?fields=email")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='email']")
|
||||||
|
refute has_element?(view, "[data-testid='member-name']")
|
||||||
|
end
|
||||||
|
end
|
||||||
73
test/mv_web/member_live/index_density_test.exs
Normal file
73
test/mv_web/member_live/index_density_test.exs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDensityTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.4 — The density toggle switches the table row-spacing token and reflects the
|
||||||
|
active density in the control state.
|
||||||
|
§1.5 — Density persists across reload per browser (session/cookie), with the
|
||||||
|
global default applying when nothing is stored.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Dense", last_name: "Row", email: "dense@example.com"},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_view_settings(view) do
|
||||||
|
view |> element("[data-testid='view-settings-button']") |> render_click()
|
||||||
|
view
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults to compact when nothing is stored", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
|
||||||
|
# The compact-mode toggle reflects the active (compact) state.
|
||||||
|
open_view_settings(view)
|
||||||
|
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggle switches the density token and the control state", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_view_settings(view)
|
||||||
|
view |> element("[data-testid='view-setting-density']") |> render_click()
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
|
||||||
|
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']")
|
||||||
|
|
||||||
|
# Toggling again returns to compact.
|
||||||
|
view |> element("[data-testid='view-setting-density']") |> render_click()
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "restores a persisted density from the session on reload", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
Plug.Test.init_test_session(conn, %{
|
||||||
|
"member_view_settings" => ~s({"density":"comfortable"})
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "restores a persisted density on the connected mount via connect params", %{conn: conn} do
|
||||||
|
# Faithful reload round-trip: the connected mount cannot read the cookie
|
||||||
|
# (the live socket's connect-info map has no cookies), so the client echoes
|
||||||
|
# the persisted value through connect params. This is the path that was
|
||||||
|
# broken in the R1 accept (cookie written but not restored on mount).
|
||||||
|
conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})})
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
|
||||||
|
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
|
||||||
|
|
@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Disables the compact Member/Address composites (per-browser view setting) so
|
||||||
|
# the individual constituent columns (first_name/last_name/email, street/…) are
|
||||||
|
# offered by the column manager and available via ?fields=.
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays all member fields in dropdown", %{conn: conn} do
|
test "displays all member fields in dropdown", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
# The individual name/address constituents are only offered when the
|
||||||
|
# compact Member/Address composites are off, so opt out of them first.
|
||||||
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
|
|
@ -148,23 +161,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest 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")
|
||||||
|
|
||||||
# Verify email is visible initially
|
# The curated Name column carries the member names, so they are visible.
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
|
|
||||||
# Open dropdown and hide email
|
# Open dropdown and hide the Name column
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Email should no longer be visible
|
# The Name column (and the names it carries) is no longer visible
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "alice@example.com"
|
refute html =~ "Anderson"
|
||||||
refute html =~ "bob@example.com"
|
refute html =~ "Brown"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
||||||
|
|
@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest 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")
|
||||||
|
|
||||||
# All fields should be visible by default
|
# The curated Name and Address columns are visible by default.
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
assert html =~ "Main St"
|
assert html =~ "Main St"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -309,17 +322,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest 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")
|
||||||
|
|
||||||
# Hide a field via dropdown
|
# Hide the curated Name column (which carries the names) via dropdown
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "alice@example.com"
|
refute html =~ "Anderson"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?fields=")
|
{:ok, _view, html} = live(conn, "/members?fields=")
|
||||||
|
|
||||||
# Should fall back to global settings
|
# Should fall back to global settings (curated Name column visible)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles invalid field names in URL", %{conn: conn} do
|
test "handles invalid field names in URL", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
||||||
|
|
||||||
# Should ignore invalid fields and use defaults
|
# Should ignore invalid fields and use defaults (curated Name column visible)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles custom field that doesn't exist", %{conn: conn} do
|
test "handles custom field that doesn't exist", %{conn: conn} do
|
||||||
|
|
@ -429,23 +442,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest 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")
|
||||||
|
|
||||||
# Verify email is visible initially
|
# The curated Name column carries the names, so they are visible initially.
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Simulate Enter key press on email field button
|
# Simulate Enter key press on the Name field button
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||||
|> render_keydown(%{key: "Enter"})
|
|> render_keydown(%{key: "Enter"})
|
||||||
|
|
||||||
# Email should no longer be visible
|
# The Name column (and the names it carries) is no longer visible
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "alice@example.com"
|
refute html =~ "Anderson"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
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
|
||||||
257
test/mv_web/member_live/index_filter_parity_property_test.exs
Normal file
257
test/mv_web/member_live/index_filter_parity_property_test.exs
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.1 — Filter result-set parity.
|
||||||
|
|
||||||
|
For generated member populations × filter-parameter combinations, the set of
|
||||||
|
member ids returned by the DB-backed `:overview` query equals the set returned
|
||||||
|
by the previous in-memory implementation (the reference oracle).
|
||||||
|
|
||||||
|
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
|
||||||
|
(boolean custom fields, date custom fields) plus the period-scoped payment
|
||||||
|
aging filter, by applying them DB-side in one arm and in-memory in the other.
|
||||||
|
"""
|
||||||
|
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.Membership.CustomField
|
||||||
|
alias Mv.Membership.CustomFieldValue
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index
|
||||||
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
@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]]
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft1 = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
ft2 = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
|
{:ok, bool_cf} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "flag_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: :boolean,
|
||||||
|
show_in_overview: true
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, date_cf} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "dt_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: :date,
|
||||||
|
show_in_overview: true
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
%{actor: actor, ft1: ft1, ft2: ft2, bool_cf: bool_cf, date_cf: date_cf}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ----- generators ---------------------------------------------------------
|
||||||
|
|
||||||
|
defp status_gen, do: StreamData.member_of([:unpaid, :paid, :suspended])
|
||||||
|
|
||||||
|
defp member_spec_gen do
|
||||||
|
StreamData.fixed_map(%{
|
||||||
|
fee_type: StreamData.member_of([:ft1, :ft2]),
|
||||||
|
cycles:
|
||||||
|
StreamData.list_of(
|
||||||
|
StreamData.tuple({StreamData.member_of(@cycle_starts), status_gen()}),
|
||||||
|
max_length: 3
|
||||||
|
),
|
||||||
|
bool: StreamData.member_of([:none, true, false]),
|
||||||
|
date: StreamData.member_of([:none | @date_values]),
|
||||||
|
in_group: StreamData.boolean()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_params_gen do
|
||||||
|
StreamData.fixed_map(%{
|
||||||
|
payment:
|
||||||
|
StreamData.member_of([
|
||||||
|
nil,
|
||||||
|
:fully_paid,
|
||||||
|
{:has_unpaid, 1},
|
||||||
|
{:has_unpaid, 2},
|
||||||
|
{:has_unpaid, 3}
|
||||||
|
]),
|
||||||
|
bool: StreamData.member_of([nil, true, false]),
|
||||||
|
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
|
||||||
|
group: StreamData.member_of([nil, :in, :not_in]),
|
||||||
|
fee_type: StreamData.member_of([nil, :in, :not_in])
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# ----- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
defp clear_members(actor) do
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_population(specs, ctx) do
|
||||||
|
Enum.map(specs, fn spec ->
|
||||||
|
ft = if spec.fee_type == :ft1, do: ctx.ft1, else: ctx.ft2
|
||||||
|
member = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, ctx.actor)
|
||||||
|
|
||||||
|
spec.cycles
|
||||||
|
|> Enum.uniq_by(fn {start, _} -> start end)
|
||||||
|
|> Enum.each(fn {start, status} ->
|
||||||
|
create_cycle(member, ft, %{cycle_start: start, status: status}, ctx.actor)
|
||||||
|
end)
|
||||||
|
|
||||||
|
maybe_set_value(member, ctx.bool_cf, spec.bool, "boolean", ctx.actor)
|
||||||
|
maybe_set_value(member, ctx.date_cf, spec.date, "date", ctx.actor)
|
||||||
|
|
||||||
|
if spec.in_group, do: add_to_group(member, ctx, ctx.actor)
|
||||||
|
{member.id, spec}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_set_value(_member, _cf, :none, _type, _actor), do: :ok
|
||||||
|
|
||||||
|
defp maybe_set_value(member, cf, value, type, actor) do
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: cf.id,
|
||||||
|
value: %{"_union_type" => type, "_union_value" => value}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_to_group(member, ctx, actor) do
|
||||||
|
Mv.Membership.create_member_group(%{member_id: member.id, group_id: ctx.group.id},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp base_opts(params, ctx) do
|
||||||
|
%{}
|
||||||
|
|> put_group(params.group, ctx)
|
||||||
|
|> put_fee_type(params.fee_type, ctx)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_group(opts, nil, _ctx), do: opts
|
||||||
|
|
||||||
|
defp put_group(opts, dir, ctx),
|
||||||
|
do: Map.merge(opts, %{group_filters: %{to_string(ctx.group.id) => dir}, groups: [ctx.group]})
|
||||||
|
|
||||||
|
defp put_fee_type(opts, nil, _ctx), do: opts
|
||||||
|
|
||||||
|
defp put_fee_type(opts, dir, ctx),
|
||||||
|
do:
|
||||||
|
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
|
||||||
|
|
||||||
|
defp moved_opts(params, ctx) do
|
||||||
|
%{}
|
||||||
|
|> put_payment(params.payment)
|
||||||
|
|> put_bool(params.bool, ctx)
|
||||||
|
|> put_date(params.date_range, ctx)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_payment(opts, nil), do: opts
|
||||||
|
|
||||||
|
defp put_payment(opts, filter),
|
||||||
|
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, bool, ctx),
|
||||||
|
do:
|
||||||
|
Map.merge(opts, %{
|
||||||
|
boolean_custom_field_filters: %{to_string(ctx.bool_cf.id) => bool},
|
||||||
|
boolean_custom_fields: [ctx.bool_cf]
|
||||||
|
})
|
||||||
|
|
||||||
|
defp put_date(opts, nil, _ctx), do: opts
|
||||||
|
|
||||||
|
defp put_date(opts, {from, to}, ctx),
|
||||||
|
do:
|
||||||
|
Map.merge(opts, %{
|
||||||
|
date_filters: %{to_string(ctx.date_cf.id) => %{from: from, to: to}},
|
||||||
|
date_custom_fields: [ctx.date_cf]
|
||||||
|
})
|
||||||
|
|
||||||
|
defp db_ids(params, ctx) do
|
||||||
|
base_opts(params, ctx)
|
||||||
|
|> Map.merge(moved_opts(params, ctx))
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.read!(actor: ctx.actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp oracle_ids(params, ctx) do
|
||||||
|
base_members =
|
||||||
|
base_opts(params, ctx)
|
||||||
|
|> OverviewQuery.build()
|
||||||
|
|> Ash.Query.load([
|
||||||
|
:membership_fee_type,
|
||||||
|
{:custom_field_values, [:custom_field]},
|
||||||
|
{:membership_fee_cycles, [:membership_fee_type]}
|
||||||
|
])
|
||||||
|
|> Ash.read!(actor: ctx.actor)
|
||||||
|
|
||||||
|
base_members
|
||||||
|
|> apply_oracle_bool(params, ctx)
|
||||||
|
|> apply_oracle_date(params, ctx)
|
||||||
|
|> apply_oracle_payment(params)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_oracle_bool(members, %{bool: nil}, _ctx), do: members
|
||||||
|
|
||||||
|
defp apply_oracle_bool(members, %{bool: bool}, ctx) do
|
||||||
|
Index.apply_boolean_custom_field_filters(
|
||||||
|
members,
|
||||||
|
%{to_string(ctx.bool_cf.id) => bool},
|
||||||
|
[ctx.bool_cf]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_oracle_date(members, %{date_range: nil}, _ctx), do: members
|
||||||
|
|
||||||
|
defp apply_oracle_date(members, %{date_range: {from, to}}, ctx) do
|
||||||
|
DateFilter.apply_in_memory(
|
||||||
|
members,
|
||||||
|
%{to_string(ctx.date_cf.id) => %{from: from, to: to}},
|
||||||
|
[ctx.date_cf]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_oracle_payment(members, %{payment: nil}), do: members
|
||||||
|
|
||||||
|
defp apply_oracle_payment(members, %{payment: filter}) do
|
||||||
|
Enum.filter(members, fn m -> payment_match?(unpaid_count(m), filter) 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
|
||||||
|
check all(
|
||||||
|
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),
|
||||||
|
params <- filter_params_gen(),
|
||||||
|
max_runs: 30
|
||||||
|
) do
|
||||||
|
clear_members(ctx.actor)
|
||||||
|
group = Mv.Fixtures.group_fixture(%{name: "G#{System.unique_integer([:positive])}"})
|
||||||
|
ctx = Map.put(ctx, :group, group)
|
||||||
|
|
||||||
|
_population = create_population(specs, ctx)
|
||||||
|
|
||||||
|
assert db_ids(params, ctx) == oracle_ids(params, ctx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Disables the compact Member composite (per-browser view setting) so the
|
||||||
|
# individual first_name column is offered and selectable via ?fields=.
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -63,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
|
||||||
|
|
@ -122,10 +126,13 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
member1: member1,
|
member1: member1,
|
||||||
group1: group1
|
group1: group1
|
||||||
} do
|
} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, html} =
|
{:ok, view, html} =
|
||||||
live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
|
live(
|
||||||
|
conn,
|
||||||
|
"/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in"
|
||||||
|
)
|
||||||
|
|
||||||
assert html =~ member1.first_name
|
assert html =~ member1.first_name
|
||||||
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
|
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
|
||||||
|
|
|
||||||
86
test/mv_web/member_live/index_keyset_property_test.exs
Normal file
86
test/mv_web/member_live/index_keyset_property_test.exs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexKeysetPropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.2 — Keyset pagination completeness: concatenating every keyset page yields
|
||||||
|
exactly the full filtered set once each (no duplicates, no gaps) in a stable
|
||||||
|
total order.
|
||||||
|
|
||||||
|
§2.3 — Sort determinism with the unique `id` tie-breaker: members equal on the
|
||||||
|
sort key are ordered by `id`, and the result set equals the unsorted set.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_members(actor) do
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_all_pages(query, actor, limit) do
|
||||||
|
page = Ash.read!(query, page: [limit: limit], actor: actor)
|
||||||
|
collect(page, page.results)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect(%{more?: true} = page, acc) do
|
||||||
|
next = Ash.page!(page, :next)
|
||||||
|
collect(next, acc ++ next.results)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect(_page, acc), do: acc
|
||||||
|
|
||||||
|
property "keyset pages cover the full set once, no dupes or gaps", %{actor: actor} do
|
||||||
|
check all(
|
||||||
|
n <- integer(1..12),
|
||||||
|
limit <- integer(1..5),
|
||||||
|
sort <- member_of([:last_name, :join_date, :inserted_at]),
|
||||||
|
order <- member_of([:asc, :desc]),
|
||||||
|
max_runs: 25
|
||||||
|
) do
|
||||||
|
clear_members(actor)
|
||||||
|
# Deliberate ties on last_name so the tie-breaker is exercised.
|
||||||
|
members =
|
||||||
|
for i <- 1..n do
|
||||||
|
member_fixture_with_actor(%{last_name: "Tie#{rem(i, 3)}"}, actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
expected = MapSet.new(members, & &1.id)
|
||||||
|
|
||||||
|
query = OverviewQuery.build(%{sort_field: sort, sort_order: order})
|
||||||
|
paged = read_all_pages(query, actor, limit)
|
||||||
|
paged_ids = Enum.map(paged, & &1.id)
|
||||||
|
|
||||||
|
assert MapSet.new(paged_ids) == expected
|
||||||
|
assert length(paged_ids) == MapSet.size(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "sort with ties is a deterministic total order broken by id", %{actor: actor} do
|
||||||
|
check all(
|
||||||
|
n <- integer(2..10),
|
||||||
|
order <- member_of([:asc, :desc]),
|
||||||
|
max_runs: 25
|
||||||
|
) do
|
||||||
|
clear_members(actor)
|
||||||
|
|
||||||
|
members =
|
||||||
|
for _ <- 1..n do
|
||||||
|
member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
ordered =
|
||||||
|
OverviewQuery.build(%{sort_field: :last_name, sort_order: order})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
# All last_names equal -> order is fully determined by the id tie-breaker (asc).
|
||||||
|
assert ordered == Enum.sort(Enum.map(members, & &1.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
68
test/mv_web/member_live/index_live_region_test.exs
Normal file
68
test/mv_web/member_live/index_live_region_test.exs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexLiveRegionTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.9 — The polite live region announces the exact total matching count,
|
||||||
|
computed via a count query for the active filter set.
|
||||||
|
§1.10 — The live region element exists in the DOM before it is filled, and the
|
||||||
|
table region carries `aria-busy`.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
defp seed(n) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
Enum.each(1..n, fn i ->
|
||||||
|
idx = String.pad_leading(Integer.to_string(i), 3, "0")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Lr#{idx}", last_name: "Region", email: "lr#{idx}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "polite live region exists on first render and announces the exact total", %{conn: conn} do
|
||||||
|
seed(60)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Present-before-fill: the live region element is in the initial DOM.
|
||||||
|
assert has_element?(view, "#members-result-count[aria-live='polite']")
|
||||||
|
# Exact total, not the loaded page size (60 matched, only 50 loaded).
|
||||||
|
region = view |> element("#members-result-count") |> render()
|
||||||
|
assert region =~ "60 members"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "table region binds aria-busy to the loading flag (settled after load)", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# aria-busy is bound to @loading? (`to_string(@loading?)`), not hardcoded: once
|
||||||
|
# the page has loaded the region is not busy. It is set true across a
|
||||||
|
# filter/sort/search reload patch (push_reload/2, §1.10).
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][aria-busy='false']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "count reflects the active filter set, not the whole table", %{conn: conn} do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Findme", last_name: "Unique", email: "findme@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
seed(5)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members?query=Findme")
|
||||||
|
|
||||||
|
# Only the single matching member is counted (1 of 6 total).
|
||||||
|
region = view |> element("#members-result-count") |> render()
|
||||||
|
assert region =~ "1 member"
|
||||||
|
refute region =~ "6 member"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -38,27 +38,31 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest 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")
|
||||||
|
|
||||||
|
# The composite Member cell carries first + last name; the in-cell email
|
||||||
|
# line stays off by default, but the email is surfaced in its own column
|
||||||
|
# (compact Member field, include-email off).
|
||||||
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||||
assert html =~ field
|
assert html =~ field
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "respects show_in_overview config", %{conn: conn, member1: m} do
|
test "respects show_in_overview config", %{conn: conn} do
|
||||||
|
# Global settings still drive column visibility: hiding a curated default
|
||||||
|
# column via settings removes it, while unaffected columns stay visible.
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
fields_to_hide = [:street, :house_number]
|
|
||||||
|
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Mv.Membership.update_settings(settings, %{
|
Mv.Membership.update_settings(settings, %{
|
||||||
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
|
member_field_visibility: %{"join_date" => false}
|
||||||
})
|
})
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
# Use search query to filter to only the expected member (Alice)
|
# Use search query to filter to only the expected member (Alice)
|
||||||
# This significantly improves test performance by avoiding loading all members from other tests
|
# This significantly improves test performance by avoiding loading all members from other tests
|
||||||
{:ok, _view, html} = live(conn, "/members?query=Alice")
|
{:ok, view, _html} = live(conn, "/members?query=Alice")
|
||||||
|
|
||||||
assert html =~ "Email"
|
refute has_element?(view, "[data-testid='join_date']")
|
||||||
assert html =~ m.email
|
# The composite Name column (and its email content) is unaffected.
|
||||||
refute html =~ m.street
|
assert has_element?(view, "[data-testid='member-name']")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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,250 +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")
|
||||||
|
|
||||||
|
suspended =
|
||||||
|
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
|
||||||
|
|
|
||||||
98
test/mv_web/member_live/index_overview_query_sort_test.exs
Normal file
98
test/mv_web/member_live/index_overview_query_sort_test.exs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexOverviewQuerySortTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.3 — Sort determinism for custom-field sorts pushed to the DB.
|
||||||
|
|
||||||
|
The keyset property test covers standard fields; these examples pin the
|
||||||
|
type-aware custom-field ordering (numeric vs lexical, chronological dates) and
|
||||||
|
the NULLS-LAST behaviour for missing/empty values in both directions, which
|
||||||
|
the previous in-memory `CustomFieldSort` guaranteed.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
|
||||||
|
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
alias Mv.Membership.CustomFieldValue
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_members(actor) do
|
||||||
|
Mv.Membership.Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_field(value_type, actor) do
|
||||||
|
{:ok, field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "sort_#{value_type}_#{System.unique_integer([:positive])}",
|
||||||
|
value_type: value_type,
|
||||||
|
show_in_overview: true
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
field
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_value(member, field, union_type, union_value, actor) do
|
||||||
|
{:ok, _} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: field.id,
|
||||||
|
value: %{"_union_type" => union_type, "_union_value" => union_value}
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sorted_ids(field, order, actor) do
|
||||||
|
OverviewQuery.build(%{
|
||||||
|
sort_field: "custom_field_#{field.id}",
|
||||||
|
sort_order: order,
|
||||||
|
custom_fields: [field]
|
||||||
|
})
|
||||||
|
|> Ash.read!(actor: actor, page: [limit: 100])
|
||||||
|
|> then(& &1.results)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "integer custom field sorts numerically, missing value last in both directions", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
clear_members(actor)
|
||||||
|
field = create_field(:integer, actor)
|
||||||
|
a = member_fixture_with_actor(%{first_name: "A"}, actor)
|
||||||
|
b = member_fixture_with_actor(%{first_name: "B"}, actor)
|
||||||
|
none = member_fixture_with_actor(%{first_name: "N"}, actor)
|
||||||
|
# 9 vs 10: lexical order would place "10" before "9"; numeric must not.
|
||||||
|
set_value(a, field, "integer", 10, actor)
|
||||||
|
set_value(b, field, "integer", 9, actor)
|
||||||
|
|
||||||
|
assert sorted_ids(field, :asc, actor) == [b.id, a.id, none.id]
|
||||||
|
assert sorted_ids(field, :desc, actor) == [a.id, b.id, none.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "date custom field sorts chronologically", %{actor: actor} do
|
||||||
|
clear_members(actor)
|
||||||
|
field = create_field(:date, actor)
|
||||||
|
older = member_fixture_with_actor(%{first_name: "Older"}, actor)
|
||||||
|
newer = member_fixture_with_actor(%{first_name: "Newer"}, actor)
|
||||||
|
set_value(older, field, "date", "1981-01-29", actor)
|
||||||
|
set_value(newer, field, "date", "1986-07-02", actor)
|
||||||
|
|
||||||
|
assert sorted_ids(field, :asc, actor) == [older.id, newer.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty string value sorts last like a missing value", %{actor: actor} do
|
||||||
|
clear_members(actor)
|
||||||
|
field = create_field(:string, actor)
|
||||||
|
filled = member_fixture_with_actor(%{first_name: "Filled"}, actor)
|
||||||
|
empty = member_fixture_with_actor(%{first_name: "Empty"}, actor)
|
||||||
|
set_value(filled, field, "string", "AAA", actor)
|
||||||
|
set_value(empty, field, "string", "", actor)
|
||||||
|
|
||||||
|
assert sorted_ids(field, :asc, actor) == [filled.id, empty.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
96
test/mv_web/member_live/index_pagination_test.exs
Normal file
96
test/mv_web/member_live/index_pagination_test.exs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexPaginationTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.7 — Mount loads only one keyset page, not the whole table.
|
||||||
|
§1.8 — phx-viewport-bottom fetches and appends the next page; once the last
|
||||||
|
page is reached no further fetch is issued.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
# Matches the :overview default_limit / @page_limit in the LiveView.
|
||||||
|
@page_limit 50
|
||||||
|
|
||||||
|
defp seed_members(n) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
Enum.each(1..n, fn i ->
|
||||||
|
# Zero-padded names keep first_name-ascending order deterministic.
|
||||||
|
idx = String.pad_leading(Integer.to_string(i), 4, "0")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Page#{idx}", last_name: "Member", email: "page#{idx}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp row_count(html) do
|
||||||
|
~r/<tr[^>]*id="row-/ |> Regex.scan(html) |> length()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "mount loads exactly one page when more members exist", %{conn: conn} do
|
||||||
|
seed_members(@page_limit + 10)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert row_count(html) == @page_limit
|
||||||
|
# More rows remain, so the infinite-scroll sentinel is armed.
|
||||||
|
assert html =~ ~s(phx-viewport-bottom="load_more")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "viewport-bottom appends the next page and stops at the last page", %{conn: conn} do
|
||||||
|
seed_members(@page_limit + 10)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert row_count(html) == @page_limit
|
||||||
|
|
||||||
|
# Fetch the next page: the remaining rows are appended to the stream.
|
||||||
|
html_after = render_hook(view, "load_more", %{})
|
||||||
|
assert row_count(html_after) == @page_limit + 10
|
||||||
|
|
||||||
|
# Last page reached: the sentinel is disarmed and a further fetch is a no-op.
|
||||||
|
refute html_after =~ ~s(phx-viewport-bottom="load_more")
|
||||||
|
html_again = render_hook(view, "load_more", %{})
|
||||||
|
assert row_count(html_again) == @page_limit + 10
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selecting a row from a later page re-renders it as checked", %{conn: conn} do
|
||||||
|
seed_members(@page_limit + 10)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Load the second page so its rows are in the loaded window.
|
||||||
|
render_hook(view, "load_more", %{})
|
||||||
|
|
||||||
|
# The highest-index member sorts last and lives on the second page.
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
last_member =
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.Query.sort(first_name: :asc)
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> List.last()
|
||||||
|
|
||||||
|
html = render_click(view, "select_member", %{"id" => last_member.id})
|
||||||
|
|
||||||
|
# The re-streamed row for a later-page member reflects the selection.
|
||||||
|
assert html =~ ~s(id="row-#{last_member.id}")
|
||||||
|
assert has_element?(view, ~s(tr#row-#{last_member.id} input[type="checkbox"][checked]))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "single page does not arm the infinite-scroll sentinel", %{conn: conn} do
|
||||||
|
seed_members(3)
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert row_count(html) == 3
|
||||||
|
refute html =~ ~s(phx-viewport-bottom="load_more")
|
||||||
|
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
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue