Compare commits

..

6 commits

Author SHA1 Message Date
1f2f8aa023 Merge pull request 'chore(deps): update mix dependencies' (#552) from renovate/mix-dependencies into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #552
2026-07-12 12:28:07 +02:00
0511836895 fix(deps): pin ash_authentication below 4.14 to unblock OIDC build
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-07-11 14:29:32 +02:00
08bae3ce55
chore: disable mix hex.audit as long as unfixable security advisories can't be skipped
Some checks failed
continuous-integration/drone/push Build is failing
2026-07-06 19:51:26 +02:00
eb20c8f3b6
style: fix formatting
Some checks failed
continuous-integration/drone/push Build is failing
2026-07-06 18:12:31 +02:00
ce49d751ea
fix: missing type declaration for buttons causing new warnings
Some checks failed
continuous-integration/drone/push Build is failing
2026-07-06 17:27:01 +02:00
Renovate Bot
ba39632c38 chore(deps): update mix dependencies
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
2026-07-01 00:09:58 +00:00
118 changed files with 4495 additions and 12370 deletions

View file

@ -40,8 +40,10 @@ local step_lint = {
'mix compile --warnings-as-errors', // Check for compilation errors & warnings
'mix format --check-formatted', // Check formatting
'mix sobelow --config', // Security checks
'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities
'mix hex.audit', // Unmaintained dependencies
// mix hex.audit (Hex >= 2.5) fails on unpatched transitive advisories (cowlib, swoosh).
// mix deps.audit supports .deps_audit_ignore for accepted risks; re-enable hex.audit when
// ignore_advisories lands in Hex or those packages are patched.
'mix deps.audit --ignore-file .deps_audit_ignore',
'mix credo --strict', // Code quality hints
'mix gettext.extract --check-up-to-date', // Translations up to date
],

View file

@ -5,25 +5,6 @@ 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/),
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
### Added

View file

@ -678,78 +678,6 @@
/* 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
============================================ */
@ -845,130 +773,3 @@
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
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;
}

View file

@ -22,8 +22,6 @@ import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
import Sortable from "../vendor/sortable"
import FilterComboboxSearch from "./hooks/filter_combobox_search"
import HoverPopover from "./hooks/hover_popover"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
@ -38,27 +36,6 @@ function getBrowserTimezone() {
// Hooks for LiveView components
let Hooks = {}
// Add-filter builder hooks (member overview field-picker search).
Hooks.FilterComboboxSearch = FilterComboboxSearch
// Top-layer hover/focus popover (member overview payment badge tooltip).
Hooks.HoverPopover = HoverPopover
// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS
// property, not an HTML attribute, so it cannot be set from the server render.
// Mirror it from the data-indeterminate attribute on mount and every update
// (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
Hooks.CopyToClipboard = {
mounted() {
@ -149,29 +126,6 @@ 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)
Hooks.FocusRestore = {
mounted() {
@ -359,72 +313,6 @@ 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
Hooks.SidebarState = {
mounted() {
@ -472,77 +360,15 @@ 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, {
longPollFallbackMs: 2500,
params: {
_csrf_token: csrfToken,
timezone: getBrowserTimezone(),
view_settings: getCookie("member_view_settings")
timezone: getBrowserTimezone()
},
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
window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail

View file

@ -1,33 +0,0 @@
// 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

View file

@ -1,76 +0,0 @@
// 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

View file

@ -48,13 +48,6 @@ defmodule Mv.Membership.CustomFieldValue do
table "custom_field_values"
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
reference :member, on_delete: :delete
reference :custom_field, on_delete: :delete
@ -140,16 +133,6 @@ defmodule Mv.Membership.CustomFieldValue do
calculations do
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
# Ensure a member can only have one custom field value per custom field

View file

@ -266,18 +266,6 @@ defmodule Mv.Membership.Member do
accept [:vereinfacht_contact_id]
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
read :search do
argument :query, :string, allow_nil?: true
@ -291,7 +279,27 @@ defmodule Mv.Membership.Member do
threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
apply_fuzzy_search_filter(query, q, threshold)
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)
query
|> Ash.Query.filter(
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
)
else
query
end
end
end
@ -717,25 +725,6 @@ defmodule Mv.Membership.Member do
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :unpaid_cycle_count,
:integer,
expr(
count(membership_fee_cycles,
query: [
filter:
expr(
status == :unpaid and
(is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and
(is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to))
)
]
)
) do
description "Count of the member's unpaid cycles whose cycle_end is within the given period"
argument :period_from, :date, allow_nil?: true
argument :period_to, :date, allow_nil?: true
end
calculate :overdue_count, :integer do
description "Count of unpaid cycles that have already ended (cycle_end < today)"
# Automatically load cycles with all attributes and membership_fee_type
@ -749,13 +738,6 @@ defmodule Mv.Membership.Member do
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
identities do
identity :unique_email, [:email]
@ -1230,43 +1212,6 @@ defmodule Mv.Membership.Member do
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
# ============================================================================

View file

@ -44,10 +44,7 @@ defmodule Mv.Membership do
resources do
resource Mv.Membership.Member do
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 :overview_members, action: :overview
define :update_member, action: :update_member
define :destroy_member, action: :destroy
end

View file

@ -1,53 +0,0 @@
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

View file

@ -13,9 +13,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
- `notes` - Optional notes for this cycle
## Design Decisions
- **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.
- **No cycle_end field**: Calculated from cycle_start + interval (from fee type)
- **Amount stored per cycle**: Preserves historical amounts when fee type changes
- **Calendar-aligned cycles**: All cycles start on calendar boundaries
@ -36,16 +34,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
postgres do
table "membership_fee_cycles"
repo Mv.Repo
custom_indexes do
# Serves the period-scoped unpaid-cycle count on the member overview:
# count of a member's cycles with status = :unpaid and cycle_end in the
# active period. The composite (member_id, status, cycle_end) lets the
# aggregate seek the per-member unpaid slice and range-scan cycle_end
# without touching paid/suspended rows.
index [:member_id, :status, :cycle_end],
name: "membership_fee_cycles_member_status_end_index"
end
end
resource do
@ -58,14 +46,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
create :create do
primary? true
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
update :update do
@ -126,13 +106,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
description "Start date of the billing cycle"
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
allow_nil? false
public? true

View file

@ -1,68 +0,0 @@
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

View file

@ -38,20 +38,6 @@ defmodule Mv.Constants do
@custom_date_filter_prefix "cdf_"
@payment_period_from_param "pay_from"
@payment_period_to_param "pay_to"
@payment_filter_param "pay_filter"
@payment_count_min_param "pay_min"
@payment_count_max_param "pay_max"
@suspended_param "suspended"
@as_of_date_param "as_of_date"
@max_boolean_filters 50
@max_mailto_bulk_recipients 50
@ -176,81 +162,6 @@ defmodule Mv.Constants do
"""
def custom_date_filter_prefix, do: @custom_date_filter_prefix
@doc """
Returns the URL parameter name for the payment-aging period lower bound
(ISO-8601 date; scopes both the payment column and the payment filter).
## Examples
iex> Mv.Constants.payment_period_from_param()
"pay_from"
"""
def payment_period_from_param, do: @payment_period_from_param
@doc """
Returns the URL parameter name for the payment-aging period upper bound.
## Examples
iex> Mv.Constants.payment_period_to_param()
"pay_to"
"""
def payment_period_to_param, do: @payment_period_to_param
@doc """
Returns the URL parameter name for the payment-count filter
(`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`).
## Examples
iex> Mv.Constants.payment_filter_param()
"pay_filter"
"""
def payment_filter_param, do: @payment_filter_param
@doc """
Returns the URL parameter name for the payment open-cycle count lower bound
(used with `pay_filter=has_unpaid` for the two-sided count range).
## Examples
iex> Mv.Constants.payment_count_min_param()
"pay_min"
"""
def payment_count_min_param, do: @payment_count_min_param
@doc """
Returns the URL parameter name for the payment open-cycle count upper bound.
## Examples
iex> Mv.Constants.payment_count_max_param()
"pay_max"
"""
def payment_count_max_param, do: @payment_count_max_param
@doc """
Returns the URL parameter name for the suspended-status filter flag.
## Examples
iex> Mv.Constants.suspended_param()
"suspended"
"""
def suspended_param, do: @suspended_param
@doc """
Returns the URL parameter name for the point-in-time "active on X" membership
filter: keeps members who were active on the given ISO-8601 date X, i.e.
`join_date <= X and (exit_date is nil or exit_date > X)`.
## Examples
iex> Mv.Constants.as_of_date_param()
"as_of_date"
"""
def as_of_date_param, do: @as_of_date_param
@doc """
Returns the maximum number of boolean custom field filters allowed per request.

View file

@ -145,7 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
|> 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 maybe_load_cycles(query, false), do: query
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true) do
MembershipFeeStatus.load_cycles_for_members(query)
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp maybe_load_groups(query, false), do: query

View file

@ -305,7 +305,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount, interval, opts)
create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
else
{:ok, [], []}
end
@ -400,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = get_actor(opts)
create_opts = Helpers.ash_actor_opts(actor)
@ -414,10 +414,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
member_id: member_id,
membership_fee_type_id: fee_type_id,
amount: amount,
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
status: :unpaid
}
handle_cycle_creation_result(

View file

@ -1,6 +1,6 @@
defmodule MvWeb.Components.BulkActionsDropdown do
@moduledoc """
Single "Actions" dropdown bundling the four member bulk actions, flattened to
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
one level: open in email program (mailto), copy email addresses, export to CSV,
export to PDF.
@ -10,10 +10,10 @@ defmodule MvWeb.Components.BulkActionsDropdown do
## Scope and trigger badge
The trigger reads the `Actions` label followed by a scope badge: an emphasized
The trigger reads `Aktionen` followed by a scope badge: an emphasized
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
badge otherwise `filtered` when a search term or filter narrows the list,
`all` when nothing is selected and no search/filter is active. Only an actual
badge otherwise `gefiltert` when a search term or filter narrows the list,
`alle` 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`
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
@ -36,13 +36,11 @@ defmodule MvWeb.Components.BulkActionsDropdown do
## Event routing
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the
parent LiveView owns the `:open` state (passed back in via the `open` assign).
This lets the parent fetch the mailto recipients lazily when the dropdown opens
rather than on every selection/filter change. The copy item likewise carries an
un-targeted `phx-click="copy_emails"`, which reaches the parent's
`handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard`
hook.
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
component owns its own `:open` state. The copy item carries an *un-targeted*
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
`handle_event/3` (which keeps access to `@members`), plus the
`CopyToClipboard` hook.
"""
use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext
@ -74,13 +72,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
# The parent owns :open and passes it in. Defaulting to false keeps the
# component renderable in isolation (render_component/2) for structural tests
# that omit it.
# The parent never sets :open (the component owns it via toggle/close).
# Honouring an explicit :open assign keeps the component renderable in
# isolation (render_component/2) for structural tests.
socket =
case Map.fetch(assigns, :open) do
{:ok, open} -> assign(socket, :open, open)
:error -> assign_new(socket, :open, fn -> false end)
:error -> socket
end
{:ok, socket}
@ -100,7 +98,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
button_label={gettext("Actions")}
icon="hero-bolt"
open={@open}
phx_target={nil}
phx_target={@myself}
menu_width="w-70"
menu_align="left"
button_class="btn-secondary gap-2"
@ -216,7 +214,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do
gettext("Too many recipients for this function. Copy the addresses or export the list.")
end
# The trigger scope is shown as a badge after the "Actions" label. Only an
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
# actual selection is emphasized (primary); both the "filtered" and "all"
# scopes are muted (neutral), since neither means members are selected.
defp scope_label(assigns) do
@ -234,6 +232,12 @@ defmodule MvWeb.Components.BulkActionsDropdown do
end
end
# Open/close are handled by the parent LiveView (see "Event routing"); the
# component no longer owns dropdown state.
@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
end

View file

@ -135,7 +135,7 @@ defmodule MvWeb.CoreComponents do
<.button variant="ghost" size="sm">Edit</.button>
<.button disabled={true}>Disabled</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method data-testid form)
attr :rest, :global, include: ~w(href navigate patch method data-testid form type)
attr :variant, :string,
values: ~w(primary secondary neutral ghost outline danger link icon),
@ -143,18 +143,12 @@ defmodule MvWeb.CoreComponents do
attr :size, :string, values: ~w(sm md lg), default: "md"
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
def button(assigns) do
rest = assigns.rest
variant = assigns[:variant] || "primary"
size = assigns[:size] || "md"
active_class = if assigns[:active], do: "btn-active", else: ""
variant_classes = %{
"primary" => "btn-primary",
@ -175,9 +169,7 @@ defmodule MvWeb.CoreComponents do
base_class = Map.fetch!(variant_classes, variant)
size_class = size_classes[size]
btn_class =
[base_class, size_class, active_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns =
assigns
@ -222,27 +214,6 @@ defmodule MvWeb.CoreComponents do
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 """
Renders a non-interactive badge with WCAG-compliant contrast.
@ -279,7 +250,7 @@ defmodule MvWeb.CoreComponents do
attr :size, :any,
default: "md",
doc: "Badge size: xs | sm | md"
doc: "Badge size: sm | md"
attr :sr_label, :string,
default: nil,
@ -298,7 +269,7 @@ defmodule MvWeb.CoreComponents do
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
size_class = badge_size_class(size)
size_class = "badge-#{size}"
# 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
@ -338,13 +309,6 @@ defmodule MvWeb.CoreComponents do
defp badge_style_class("outline"), do: "badge-outline"
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 """
Renders a visually empty table cell with screen-reader-only text (WCAG).
@ -479,11 +443,6 @@ defmodule MvWeb.CoreComponents do
attr :selected, :map, default: %{}
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_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 :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)"
@ -587,19 +546,6 @@ defmodule MvWeb.CoreComponents do
>
{gettext("None")}
</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>
</li>
@ -992,11 +938,6 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field"
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,
default: false,
doc:
@ -1012,11 +953,6 @@ defmodule MvWeb.CoreComponents do
doc:
"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
attr :label, :string
attr :class, :string
@ -1028,10 +964,6 @@ defmodule MvWeb.CoreComponents do
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
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
@ -1054,15 +986,6 @@ defmodule MvWeb.CoreComponents do
assigns =
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"""
<div
id={@row_click && "#{@id}-keyboard"}
@ -1070,31 +993,20 @@ defmodule MvWeb.CoreComponents do
data-sticky-first-col-rows={@sticky_first_col && "true"}
phx-hook={@row_click && "TableRowKeydown"}
>
<table class={["table table-zebra", @size_class]}>
<table class="table table-zebra">
<thead>
<tr>
<th
:for={{col, col_idx} <- Enum.with_index(@col)}
class={[
table_th_class(col, @sticky_header),
@sticky_first_col && col_idx == 0 &&
"sticky-first-col-th sticky left-0 z-30 bg-base-100"
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
]}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
{col[:label]}
</th>
<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
)
}
>
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@ -1109,24 +1021,16 @@ defmodule MvWeb.CoreComponents do
</th>
</tr>
</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
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={
[
class={[
table_row_tr_class(
table_row_selected?(assigns, row),
@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-selected={table_row_selected?(assigns, row) && "true"}
title={@row_click && @row_tooltip}
@ -1199,37 +1103,8 @@ defmodule MvWeb.CoreComponents do
<td
:for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)}
class={[
"max-w-xs",
dyn_col[:custom_field].value_type != :boolean && "truncate",
@row_click && "hover:cursor-pointer"
]}
class={["max-w-xs 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
rendered = dyn_col[:render].(@row_item.(row))
@ -1241,7 +1116,6 @@ defmodule MvWeb.CoreComponents do
else
""
end}
<% end %>
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
@ -1252,21 +1126,6 @@ defmodule MvWeb.CoreComponents do
</td>
</tr>
</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>
</div>
"""
@ -1301,8 +1160,8 @@ defmodule MvWeb.CoreComponents do
end
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
# bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll.
# Sort tooltips use the Popover API (top layer) and need no header z-lift.
# hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10)
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background.
defp table_th_class(col, sticky_header) do
base = Map.get(col, :class)
sticky = if sticky_header, do: sticky_th_classes(), else: nil
@ -1314,7 +1173,7 @@ defmodule MvWeb.CoreComponents do
defp table_th_sticky_class(_), do: nil
defp sticky_th_classes,
do: "lg:sticky lg:top-0 bg-base-100 z-10"
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
@doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support.

View file

@ -26,8 +26,7 @@ defmodule MvWeb.Layouts.Sidebar do
aria-label={gettext("Close sidebar")}
class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"
tabindex="-1"
>
</label>
></label>
<aside id="main-sidebar" class="sidebar" aria-label={gettext("Main navigation")}>
{sidebar_header(assigns)}
<%= if @current_user do %>
@ -174,8 +173,7 @@ defmodule MvWeb.Layouts.Sidebar do
<span
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary"
aria-hidden="true"
>
</span>
></span>
<% end %>
</span>
<span class="menu-label">{@label}</span>

View file

@ -294,7 +294,7 @@ defmodule MvWeb.MemberExportController do
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|> maybe_load_cycles(need_cycles)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
@ -338,10 +338,10 @@ defmodule MvWeb.MemberExportController do
end
end
defp maybe_load_cycles(query, false), do: query
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true) do
MembershipFeeStatus.load_cycles_for_members(query)
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp maybe_load_groups(query, false), do: query

File diff suppressed because it is too large Load diff

View file

@ -69,13 +69,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Columns")}
button_label={gettext("Show/Hide Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
open={@open}
show_select_buttons={true}
show_reset_button={true}
phx_target={@myself}
/>
</div>
@ -116,13 +115,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
{:noreply, assign(socket, :selected_fields, all)}
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
def handle_event("select_none", _params, socket) do
none =

File diff suppressed because it is too large Load diff

View file

@ -49,17 +49,6 @@ defmodule MvWeb.Components.SearchBarComponent do
phx-target={@myself}
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>
</form>
"""
@ -72,10 +61,4 @@ defmodule MvWeb.Components.SearchBarComponent do
send(self(), {:search_changed, q})
{:noreply, assign(socket, :query, q)}
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

View file

@ -6,57 +6,41 @@ defmodule MvWeb.Components.SortHeaderComponent do
- label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview
- 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
@impl true
def update(assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
{:ok, assign(socket, assigns)}
end
# Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
~H"""
<div>
<button
id={"sort-btn-#{@field}"}
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
<.button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
aria-describedby={@tooltip_id}
class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
variant="ghost"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="select-none"
phx-click="sort"
phx-value-field={@field}
phx-hook="SortTooltip"
data-tooltip-id={@tooltip_id}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="sort-icon"
/>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="sort-icon opacity-40"
class="opacity-40"
/>
<% end %>
</button>
<div id={@tooltip_id} popover="manual" role="tooltip" class="sort-tooltip">
{aria_sort(@field, @sort_field, @sort_order, @hint)}
</div>
</.button>
</.tooltip>
</div>
"""
end
@ -64,13 +48,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir, hint) when field == sort_field do
defp aria_sort(field, sort_field, dir) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> hint
nil -> gettext("Click to sort")
end
end
defp aria_sort(_, _, _, hint), do: hint
defp aria_sort(_, _, _), do: gettext("Click to sort")
end

View file

@ -1,129 +0,0 @@
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

View file

@ -5,7 +5,6 @@
<.live_component
module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json}
selected_count={@selected_count}
scope={@scope}
@ -21,9 +20,6 @@
</:actions>
</.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">
<.live_component
module={MvWeb.Components.SearchBarComponent}
@ -31,28 +27,10 @@
query={@query}
placeholder={gettext("Search...")}
/>
<div class="ml-auto flex items-center gap-4">
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.live_component
module={MvWeb.Components.ViewSettingsDropdownComponent}
id="view-settings-dropdown"
density={@density}
compact_member={@compact_member}
member_include_email={@member_include_email}
compact_address={@compact_address}
/>
</div>
</div>
<.live_component
module={MvWeb.Components.AddFilterBuilderComponent}
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
fee_types={@fee_types}
@ -61,24 +39,45 @@
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
payment_filter={@payment_filter}
payment_period={@payment_period}
suspended={@suspended}
as_of_date={@as_of_date}
member_count={length(@members)}
/>
</div>
<%!-- Polite live region: present on first render (before it is filled) so
screen readers announce the exact total matching count on every filter change
(WCAG 4.1.3). --%>
<div
id="members-result-count"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
<.tooltip
content={
gettext(
"Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
)
}
position="top"
>
{ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)}
<.button
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>
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
@ -89,98 +88,49 @@
data-testid="members-table-scroll"
role="region"
aria-label={gettext("Members table")}
aria-busy={to_string(@loading?)}
data-density={@density}
tabindex="0"
>
<.table
id="members"
rows={@streams.members}
row_item={fn {_dom_id, member} -> member end}
rows={@members}
wrapper_overflow_class="overflow-visible"
sticky_header={true}
sticky_first_col={true}
viewport_bottom={@more? && "load_more"}
row_click={
fn {_dom_id, member} ->
JS.push("select_row_and_navigate", value: %{id: member.id})
end
}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
row_tooltip={gettext("Click for member details")}
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col
:let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={
~H"""
<input
<.input
type="checkbox"
id="select-all-checkbox"
name="select_all"
class="checkbox checkbox-sm"
phx-click="select_all"
phx-hook="IndeterminateCheckbox"
data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)}
checked={@total_count > 0 and @selected_count == @total_count}
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")}
role="checkbox"
/>
"""
}
>
<input
<.input
type="checkbox"
name={member.id}
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_members, member.id)}
aria-label={gettext("Select member")}
role="checkbox"
/>
</: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
:let={member}
:if={:first_name in @member_fields_visible}
sort_field={:first_name}
label={
~H"""
<.live_component
@ -199,7 +149,6 @@
<:col
:let={member}
:if={:last_name in @member_fields_visible}
sort_field={:last_name}
label={
~H"""
<.live_component
@ -218,7 +167,6 @@
<:col
:let={member}
:if={:email in @member_fields_visible}
sort_field={:email}
label={
~H"""
<.live_component
@ -234,127 +182,9 @@
>
{member.email}
</: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
:let={member}
:if={:join_date in @member_fields_visible}
sort_field={:join_date}
label={
~H"""
<.live_component
@ -373,7 +203,6 @@
<:col
:let={member}
:if={:exit_date in @member_fields_visible}
sort_field={:exit_date}
label={
~H"""
<.live_component
@ -399,7 +228,6 @@
<:col
:let={member}
:if={:country in @member_fields_visible}
sort_field={:country}
label={
~H"""
<.live_component
@ -415,10 +243,81 @@
>
{member.country}
</: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
:let={member}
:if={:membership_fee_start_date in @member_fields_visible}
sort_field={:membership_fee_start_date}
label={
~H"""
<.live_component
@ -437,7 +336,6 @@
<:col
:let={member}
:if={:membership_fee_type in @member_fields_visible}
sort_field={:membership_fee_type}
label={
~H"""
<.live_component
@ -458,122 +356,22 @@
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
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"
label={gettext("Membership Fee Status")}
>
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %>
<.badge variant={badge.variant}>
<.icon name={badge.icon} class="size-4" />
{badge.label}
</.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>
</li>
<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>
<% else %>
<.empty_cell sr_text={gettext("No cycle")} />
<% end %>
</:col>
<:col
:let={member}
:if={:groups in @member_fields_visible}
sort_field={:groups}
label={
~H"""
<.live_component
@ -588,7 +386,6 @@
}
>
<.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 %>
<.badge
variant="primary"
@ -598,7 +395,6 @@
{group.name}
</.badge>
<% end %>
</div>
</.maybe_value>
</:col>
<:action :let={member}>
@ -608,37 +404,6 @@
</.link>
</div>
</: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>
</div>
</Layouts.app>

View file

@ -1,37 +0,0 @@
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

View file

@ -1,28 +0,0 @@
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

View file

@ -67,52 +67,6 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
}
end
@typedoc """
The three-state active/former quick filter. It is not a stored field but a
view onto `exit_date.mode`: `:active` `:active_only`, `:former`
`:inactive_only`, `:all` `:all`. A detailed `:custom` exit-date selection
has no quick equivalent and surfaces as `:custom` (no quick chip selected).
"""
@type quick_state :: :active | :former | :all | :custom
@quick_to_mode %{active: :active_only, former: :inactive_only, all: :all}
@doc """
Derives the active/former quick-filter state from the single `exit_date`
source. Total function; an absent or malformed `exit_date` reads as the
default `:active`. This is the only reader there is no second state to keep
in sync (§2.4).
"""
@spec quick_state(map()) :: quick_state()
def quick_state(filters) when is_map(filters) do
case Map.get(filters, :exit_date, %{}) do
%{mode: :active_only} -> :active
%{mode: :inactive_only} -> :former
%{mode: :all} -> :all
%{mode: :custom} -> :custom
_ -> :active
end
end
@doc """
Applies an active/former quick-filter choice onto the date filter state by
writing the corresponding `exit_date` mode and clearing its bounds. Only the
`exit_date` slice is touched; `join_date` and custom-date entries are left
intact, so the quick filter and the detailed control share one source.
"""
@spec set_quick_state(map(), :active | :former | :all) :: %{
:exit_date => %{
from: nil,
mode: :active_only | :inactive_only | :all,
to: nil
},
optional(any()) => any()
}
def set_quick_state(filters, state)
when is_map(filters) and state in [:active, :former, :all] do
Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil})
end
@doc """
Decodes URL params into a date filter state map.
@ -317,54 +271,6 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
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 """
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`

View file

@ -1,70 +0,0 @@
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

View file

@ -1,162 +0,0 @@
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

View file

@ -29,8 +29,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
"""
alias MvWeb.MemberLive.Index.Cookie
@cookie_name "member_field_selection"
@cookie_max_age 365 * 24 * 60 * 60
@session_key "member_field_selection"
@ -82,7 +80,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
%{}
[cookie_header | _rest] ->
cookies = Cookie.parse_header(cookie_header)
cookies = parse_cookie_header(cookie_header)
case Map.get(cookies, @cookie_name) do
nil -> %{}
@ -92,6 +90,19 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
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 """
Saves field selection to cookie.

View file

@ -29,183 +29,51 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# 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).
# :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]
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
# Export/API may accept this as alias; must not appear in the UI options list.
@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
Mv.Constants.member_fields() ++ @pseudo_member_fields
end
@doc """
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.
Gets all available fields for selection.
Use when `?fields=...` is in the URL so column visibility is not merged with
global settings.
Returns a list of field identifiers:
- Member fields as atoms (e.g., `:first_name`, `:email`)
- 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 selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields, opts)
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))
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
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
def selection_from_url_only(_, _, _), do: %{}
@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()
}
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
all_fields = get_all_available_fields(custom_fields)
defp do_selection_from_url_only(url_selection, all_fields) do
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visible = Map.get(url_selection, field_string, false)
@ -213,38 +81,40 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
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
def selection_from_url_only(_, _), do: %{}
@doc """
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`).
Merges user field selection with global settings.
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, 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
the composite "Name" is on) never leaks into the visible set. Defaults follow
the mode via `default_visible_set/3`.
"""
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
%{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
when is_list(opts) do
{cm, ie, ca} = view_opts(opts)
all_fields = get_offered_fields(custom_fields, cm, ie, ca)
default_set = default_visible_set(cm, ie, ca)
do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
end
user selection, the global setting is used. If a field is not in global settings,
it defaults to `true` (visible).
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)
## Parameters
- `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(
%{String.t() => boolean()},
map(),
[struct()]
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields)
global_visibility = get_global_visibility_map(global_settings, custom_fields)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
@ -259,6 +129,35 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
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 """
Gets visible member fields from field selection.
@ -360,15 +259,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings
defp get_global_visibility_map(settings, custom_fields, default_set) do
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
defp get_global_visibility_map(settings, custom_fields) do
member_visibility = get_member_field_visibility_from_settings(settings)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings, default_set) do
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
@ -377,13 +276,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
default_visibility = MapSet.member?(default_set, field)
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
Map.put(acc, Atom.to_string(field), true)
end)
end

View file

@ -1,145 +0,0 @@
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

View file

@ -1,474 +0,0 @@
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

View file

@ -1,395 +0,0 @@
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.yyyydd.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

View file

@ -1,175 +0,0 @@
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

View file

@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do
end
@impl true
def handle_params(%{"id" => id} = params, _, socket) do
def handle_params(%{"id" => id}, _, socket) do
actor = current_actor(socket)
# Load custom fields once using assign_new to avoid repeated queries
@ -463,17 +463,9 @@ defmodule MvWeb.MemberLive.Show do
{:noreply,
socket
|> Layouts.assign_page_title(content_title)
|> assign(:member, member)
|> maybe_assign_tab(params)}
|> assign(:member, member)}
end
# Deep-link support: the overview payment badge drills into the fees history
# via `?tab=membership_fees` (§1.15).
defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}),
do: assign(socket, :active_tab, :membership_fees)
defp maybe_assign_tab(socket, _params), do: socket
@impl true
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
{:noreply, assign(socket, :active_tab, :contact)}

View file

@ -152,11 +152,9 @@ defmodule MvWeb.StatisticsLive do
<p class="text-xs text-base-content/70 mt-2">
<span class="inline-block w-2 h-2 rounded-full bg-success align-middle mr-1"></span>
{gettext("Paid")}
<span class="inline-block w-2 h-2 rounded-full bg-warning align-middle mx-2 mr-1">
</span>
<span class="inline-block w-2 h-2 rounded-full bg-warning align-middle mx-2 mr-1"></span>
{gettext("Unpaid")}
<span class="inline-block w-2 h-2 rounded-full bg-base-content/20 align-middle mx-2 mr-1">
</span>
<span class="inline-block w-2 h-2 rounded-full bg-base-content/20 align-middle mx-2 mr-1"></span>
{gettext("Suspended")}
</p>
</div>
@ -271,8 +269,7 @@ defmodule MvWeb.StatisticsLive do
class="inline-block w-2 h-2 rounded-full bg-success shrink-0"
aria-hidden="true"
title={gettext("Joins")}
>
</span>
></span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
@ -282,8 +279,7 @@ defmodule MvWeb.StatisticsLive do
class="inline-block w-2 h-2 rounded-full bg-error shrink-0"
aria-hidden="true"
title={gettext("Exits")}
>
</span>
></span>
</span>
</td>
</tr>
@ -437,8 +433,7 @@ defmodule MvWeb.StatisticsLive do
class="inline-block w-2 h-2 rounded-full bg-success shrink-0"
aria-hidden="true"
title={gettext("Paid")}
>
</span>
></span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
@ -448,8 +443,7 @@ defmodule MvWeb.StatisticsLive do
class="inline-block w-2 h-2 rounded-full bg-warning shrink-0"
aria-hidden="true"
title={gettext("Unpaid")}
>
</span>
></span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
@ -459,8 +453,7 @@ defmodule MvWeb.StatisticsLive do
class="inline-block w-2 h-2 rounded-full bg-base-content/20 shrink-0"
aria-hidden="true"
title={gettext("Suspended")}
>
</span>
></span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">

View file

@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
## Parameters
- `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
@ -31,8 +33,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
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`.
"""
@spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t()
def load_cycles_for_members(query) do
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
# Load membership_fee_type and cycles
query
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
@ -156,6 +158,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
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
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")

View file

@ -32,8 +32,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:membership_fee_type), do: gettext("Fee Type")
def label(:groups), do: gettext("Groups")
def label(:name), do: gettext("Name")
def label(:address), do: gettext("Address")
# Fallback for unknown fields
def label(field) do

14
mix.exs
View file

@ -37,7 +37,7 @@ defmodule Mv.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:tidewave, "~> 0.5", only: [:dev]},
{:tidewave, "~> 0.6", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 1.0", only: [:dev]},
{:ash_admin, "~> 1.0"},
@ -45,7 +45,11 @@ defmodule Mv.MixProject do
{:ash_phoenix, "~> 2.0"},
{:ash, "~> 3.0"},
{:bcrypt_elixir, "~> 3.0"},
{:ash_authentication, "~> 4.9"},
# Pinned below 4.14 until the identity_resource / UserIdentity migration lands.
# 4.14 makes an `identity_resource` mandatory for OIDC strategies; our custom
# `oidc_id` design already resolves users by the `sub` claim (never by email),
# so there is no live security gap. Unpin once UserIdentity + data migration exist.
{:ash_authentication, "~> 4.13.0"},
{:ash_authentication_phoenix, "~> 2.10"},
{:igniter, "~> 0.8", only: [:dev, :test]},
{:phoenix, "~> 1.8.0-rc.4", override: true},
@ -54,11 +58,11 @@ defmodule Mv.MixProject do
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0-rc.3"},
{:phoenix_live_view, "~> 1.2.0"},
{:lazy_html, ">= 0.0.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.4", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.5", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.2.0",
@ -70,7 +74,7 @@ defmodule Mv.MixProject do
{:swoosh, "~> 1.16"},
# Required by Swoosh.Adapters.SMTP (and its Helpers use mimemail, which gen_smtp brings in)
{:gen_smtp, "~> 1.0"},
{:req, "~> 0.5"},
{:req, "~> 0.6"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"},

View file

@ -1,51 +1,51 @@
%{
"ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
"ash": {:hex, :ash, "3.29.3", "ba3c286c10fef489e01e1896bb6e80dbcac357c55b6d375da9e9eb7b49e88234", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2a7a45d3a3b980457b5cc7e339a84a35c232a7ab99047bbf7ce55e7dc36df357"},
"ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"},
"ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"},
"ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"},
"ash_postgres": {:hex, :ash_postgres, "2.10.0", "831d3b70b80560a6894cb17c98e3f72e3dbbc06e835dbac36363c27e186b1940", [:mix], [{:ash, "~> 3.29", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "e73298910d29b8051b30af390094165848b588288204ab12990f365c9fc653cc"},
"ash_sql": {:hex, :ash_sql, "0.6.5", "4c64ccee3cbe7101a088aa84df54da9ce3622f7fb14cdbfc3e9fa9e010a5baa4", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "e054670f5cf59e5dd2b13695c5de02ade592dc2f012ece10adb98a87e94cca8b"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"circular_buffer": {:hex, :circular_buffer, "1.0.1", "01f0e3d5fe945080692cf6521c0988e9dbb5dc312831cefe77e5b63a4e658160", [:mix], [], "hexpm", "7d4ece3137d49c1f8dd0b3e0aa7c484f4d83a0be5d4b516c282085c1d5f2d7b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy": {:hex, :cowboy, "2.17.0", "059d6ae769214cedf55d115ce5bf38649ff6b32ec693067cf6b185d0ab1b53c3", [:make, :rebar3], [{:cowlib, ">= 2.18.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "84f0e4c9d5820342cd506472052b79336d0d9d3624e46303f6d0af9dc94e8d5f"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"cowlib": {:hex, :cowlib, "2.18.0", "4a3ef8cb012c21c7cbb7c925f75405e26d7fcd393fd78c46187539dbc9a48310", [:make, :rebar3], [], "hexpm", "c4f89d33a61162d4cfdcb5a1af7e562d80a700b2573cc71020ce6521b152dc1a"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"crux": {:hex, :crux, "0.1.4", "1fa21f5ca886d3498f83871a3ef19379b80abf8cfcf2ed32007e80279e714f1e", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ff7d880cf732d82360aa81e439b8d4831cd30768061c9233f90c97e10162b9c0"},
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
"ex_ast": {:hex, :ex_ast, "0.12.7", "ba3aa94140345b0688f9c5dea62b5d558536cc5f2ff7e37466f884e85aca0964", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "62d3967d3308868db56c51803ff3908a632609d0fd39d6a609032bff49a34f42"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hammer": {:hex, :hammer, "7.3.0", "2e9759b2aea75d070eacd7f7239d5ea74991762788a22223d3cc1d555c7475a0", [:mix], [], "hexpm", "cfd88c4075c03be92b2fbbcb35af354315e21c6dd47adf7947a2d500cbebef2c"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"},
"igniter": {:hex, :igniter, "0.8.2", "28621d9d2559c86a0249a195d9a7c55e8967b781850feff0bce6c4013b75f1c3", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "89146ad3fba21f3ea10873ae509fc760618623d4578e4723929a9dd1532aa30f"},
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
@ -56,50 +56,50 @@
"live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
"owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"},
"phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.5", "29fdf6be32b88395d82cce9f31c154fe0dd9538c9bc6bca78ae4a569cb8b7f8a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "864e71ee13e676a2bdecb09698054450ad1c21f14b0863238d44ed9f213a269a"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
"plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"},
"plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
"sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"},
"spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"},
"spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"},
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
"tidewave": {:hex, :tidewave, "0.6.1", "53ab0ee06b0bc1225aeebdc56cdb8143e47cbdca47c34a1cb9d3406ef7a51f3c", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d81b021814709a0de5bd2c6db814f026ae29bd431bf5e0f48c841edbb05480f4"},
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

View file

@ -20,6 +20,13 @@ msgstr " (Datenfeld: %{field})"
msgid "%{count} failed"
msgstr "%{count} fehlgeschlagen"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} filter active"
msgid_plural "%{count} filters active"
msgstr[0] "%{count} Filter aktiv"
msgstr[1] "%{count} Filter aktiv"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
@ -32,6 +39,16 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z
msgid "%{count} synced"
msgstr "%{count} synchronisiert"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr "%{field} von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr "%{field} bis"
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@ -89,6 +106,11 @@ msgstr "Aktionen"
msgid "Active members"
msgstr "Aktive Mitglieder"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr "Nur aktive"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
@ -104,9 +126,7 @@ msgstr "Feld hinzufügen"
msgid "Add members"
msgstr "Mitglieder hinzufügen"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr "Adresse"
@ -127,7 +147,7 @@ msgid "Administration"
msgstr "Administration"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
@ -172,7 +192,8 @@ 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."
#: lib/mv_web/helpers/ash_error_helpers.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
@ -188,6 +209,11 @@ msgstr "App-URL (Link zur Kontaktansicht)"
msgid "Applicant data"
msgstr "Angaben des Antragstellers"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Apply filters"
msgstr "Filter auswählen"
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
@ -462,6 +488,11 @@ msgstr "CSV-Datei auswählen"
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Clear filters"
msgstr "Filter zurücksetzen"
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
@ -472,6 +503,11 @@ msgstr "Klicken für Details"
msgid "Click for group details"
msgstr "Klicke für Gruppen-Details"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for member details"
msgstr "Klicke für Mitglieds-Details"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for role details"
@ -513,6 +549,11 @@ msgstr "Client-ID"
msgid "Client Secret"
msgstr "Client-Geheimnis"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Close"
msgstr "Schließen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
@ -774,6 +815,11 @@ msgstr "Aktueller Zyklus"
msgid "Current amount"
msgstr "Aktueller Betrag"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current payment cycle"
msgstr "Aktueller Zahlungszyklus"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
@ -784,6 +830,11 @@ msgstr "Benutzerdefiniert"
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Custom date fields"
msgstr "Benutzerdefinierte Datumsfelder"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
@ -863,6 +914,11 @@ msgstr "Datenfelder"
msgid "Date"
msgstr "Datum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Dates"
msgstr "Daten"
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Deactivate"
@ -1239,12 +1295,22 @@ msgstr "Beispiele"
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date from"
msgstr "Austrittsdatum von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date to"
msgstr "Austrittsdatum bis"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
@ -1403,7 +1469,6 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee type"
@ -1419,6 +1484,11 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart."
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Fee types"
msgstr "Beitragsarten"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee types could not be loaded."
@ -1435,6 +1505,11 @@ msgstr "Feld"
msgid "Fields on the join form"
msgstr "Felder im Beitrittsformular"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr "Mitglieder filtern"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -1454,7 +1529,7 @@ msgstr "Vorname"
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr "Von"
@ -1581,6 +1656,7 @@ msgid "Group saved successfully."
msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
@ -1695,6 +1771,11 @@ msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden."
msgid "Inactive members"
msgstr "Inaktive Mitglieder"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr "Nur ehemalige"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
@ -1730,6 +1811,7 @@ msgstr "Falsche E-Mail oder Passwort"
msgid "Individual Datafields"
msgstr "Individuelle Datenfelder"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Individual datafields"
@ -1822,11 +1904,21 @@ msgstr "Beitrittsformular"
msgid "Join confirmation"
msgstr "Beitrittsbestätigung"
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date from"
msgstr "Beitrittsdatum von"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date to"
msgstr "Beitrittsdatum bis"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
@ -1916,6 +2008,11 @@ msgstr "Nachname"
msgid "Last name"
msgstr "Nachname"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last payment cycle"
msgstr "Letzter Zahlungszyklus"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
@ -2010,7 +2107,6 @@ msgstr "Als ausgesetzt markieren"
msgid "Mark as unpaid"
msgstr "Als unbezahlt markieren"
#: lib/mv_web/live/member_live/index/payment_aging.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
@ -2057,6 +2153,11 @@ msgstr "Mitgliedsfeld"
msgid "Member field %{action} successfully"
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Member filter"
msgstr "Mitgliedsfilter"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
@ -2142,6 +2243,7 @@ msgstr "Mitgliedsbeitrag"
msgid "Membership Fee Start Date"
msgstr "Startdatum Mitgliedsbeitrag"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status"
@ -2186,12 +2288,14 @@ msgstr "Beitragseinstellungen"
msgid "Membership fee start"
msgstr "Beitragsbeginn"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type deleted"
msgstr "Mitgliedsbeitragsart gelöscht"
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
msgstr "Mitgliedsbeitragsart nicht gefunden"
@ -2246,14 +2350,12 @@ msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#: 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/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_type_live/form.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/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -2304,8 +2406,7 @@ msgstr "Neue*r Benutzer*in"
msgid "New amount"
msgstr "Neuer Betrag"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@ -2327,6 +2428,11 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
msgid "No approved or rejected requests yet"
msgstr "Noch keine genehmigten oder abgelehnten Anträge"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No cycle"
msgstr "Kein Zyklus"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
@ -2586,10 +2692,11 @@ msgid "Options"
msgstr "Optionen"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
@ -2621,6 +2728,16 @@ msgstr "Beitragsdaten"
msgid "Payment Interval"
msgstr "Zahlungsintervall"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Status"
msgstr "Bezahlstatus"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr "Zahlungen"
#: lib/mv_web/live/join_request_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
@ -2719,6 +2836,11 @@ msgstr "Vierteljährlich"
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr "Zeitraum"
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Reactivate member"
@ -3138,6 +3260,11 @@ msgstr "Server nicht erreichbar. Host und Port prüfen."
msgid "Set Password"
msgstr "Passwort setzen"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully."
@ -3166,6 +3293,11 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
msgid "Show in overview"
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
#, elixir-autogen, elixir-format
msgid "Sign in"
@ -3259,11 +3391,10 @@ msgid "Summary"
msgstr "Zusammenfassung"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr "Pausiert"
@ -3468,7 +3599,7 @@ msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier f
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität"
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr "Bis"
@ -3568,10 +3699,11 @@ msgid "Unlinking scheduled"
msgstr "Aufhebung der Verknüpfung geplant"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr "Unbezahlt"
@ -3772,8 +3904,7 @@ msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen"
msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@ -3825,7 +3956,8 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte
msgid "You do not have permission to %{action} members."
msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}."
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen."
@ -3841,7 +3973,8 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
msgid "You do not have permission to delete this member"
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen."
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen."
@ -3946,7 +4079,6 @@ msgid "email %{email} has already been taken"
msgstr "E-Mail %{email} wurde bereits verwendet"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "filtered"
msgstr "gefiltert"
@ -3997,6 +4129,11 @@ msgstr "aktualisiert"
msgid "updated"
msgstr "aktualisiert"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "without %{name}"
msgstr "ohne %{name}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
@ -4008,452 +4145,3 @@ msgstr "Öffnen"
msgctxt "status"
msgid "Open"
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"

View file

@ -21,6 +21,13 @@ msgstr ""
msgid "%{count} failed"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} filter active"
msgid_plural "%{count} filters active"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this datafield."
@ -33,6 +40,16 @@ msgstr[1] ""
msgid "%{count} synced"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@ -90,6 +107,11 @@ msgstr ""
msgid "Active members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
@ -105,9 +127,7 @@ msgstr ""
msgid "Add members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr ""
@ -128,7 +148,7 @@ msgid "Administration"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
@ -189,6 +209,11 @@ msgstr ""
msgid "Applicant data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Apply filters"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
@ -463,6 +488,11 @@ msgstr ""
msgid "City"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Clear filters"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
@ -473,6 +503,11 @@ msgstr ""
msgid "Click for group details"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for member details"
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for role details"
@ -514,6 +549,11 @@ msgstr ""
msgid "Client Secret"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Close"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
@ -775,6 +815,11 @@ msgstr ""
msgid "Current amount"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current payment cycle"
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
@ -785,6 +830,11 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Custom date fields"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
@ -864,6 +914,11 @@ msgstr ""
msgid "Date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Dates"
msgstr ""
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Deactivate"
@ -1240,12 +1295,22 @@ msgstr ""
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date to"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
@ -1404,7 +1469,6 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee type"
@ -1420,6 +1484,11 @@ msgstr ""
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Fee types"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee types could not be loaded."
@ -1436,6 +1505,11 @@ msgstr ""
msgid "Fields on the join form"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -1455,7 +1529,7 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
@ -1582,6 +1656,7 @@ msgid "Group saved successfully."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
@ -1696,6 +1771,11 @@ msgstr ""
msgid "Inactive members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
@ -1731,6 +1811,7 @@ msgstr ""
msgid "Individual Datafields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Individual datafields"
@ -1823,11 +1904,21 @@ msgstr ""
msgid "Join confirmation"
msgstr ""
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Join date to"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
@ -1917,6 +2008,11 @@ msgstr ""
msgid "Last name"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last payment cycle"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
@ -2011,7 +2107,6 @@ msgstr ""
msgid "Mark as unpaid"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
@ -2058,6 +2153,11 @@ msgstr ""
msgid "Member field %{action} successfully"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Member filter"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
@ -2143,6 +2243,7 @@ msgstr ""
msgid "Membership Fee Start Date"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Status"
@ -2247,14 +2348,12 @@ msgstr ""
#: 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/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_type_live/form.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/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -2305,8 +2404,7 @@ msgstr ""
msgid "New amount"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@ -2328,6 +2426,11 @@ msgstr ""
msgid "No approved or rejected requests yet"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No cycle"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
@ -2587,10 +2690,11 @@ msgid "Options"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -2622,6 +2726,16 @@ msgstr ""
msgid "Payment Interval"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment Status"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
#: lib/mv_web/live/join_request_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
@ -2720,6 +2834,11 @@ msgstr ""
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr ""
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Reactivate member"
@ -3139,6 +3258,11 @@ msgstr ""
msgid "Set Password"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings saved successfully."
@ -3167,6 +3291,11 @@ msgstr ""
msgid "Show in overview"
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
#, elixir-autogen, elixir-format
msgid "Sign in"
@ -3260,11 +3389,10 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
@ -3469,7 +3597,7 @@ msgstr ""
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
@ -3569,10 +3697,11 @@ msgid "Unlinking scheduled"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
@ -3772,8 +3901,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@ -3946,7 +4074,6 @@ msgid "email %{email} has already been taken"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "filtered"
msgstr ""
@ -3997,6 +4124,11 @@ msgstr ""
msgid "updated"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "without %{name}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
@ -4008,377 +4140,3 @@ msgstr ""
msgctxt "status"
msgid "Open"
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 ""

View file

@ -21,6 +21,13 @@ msgstr ""
msgid "%{count} failed"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} filter active"
msgid_plural "%{count} filters active"
msgstr[0] "%{count} filter active"
msgstr[1] "%{count} filters active"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
@ -33,6 +40,16 @@ msgstr[1] ""
msgid "%{count} synced"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "%{field} to"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@ -90,6 +107,11 @@ msgstr ""
msgid "Active members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Active only"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Add Member"
@ -105,9 +127,7 @@ msgstr ""
msgid "Add members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Address"
msgstr ""
@ -128,7 +148,7 @@ msgid "Administration"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
@ -173,7 +193,8 @@ msgid "An account with this email already exists. Please verify your password to
msgstr ""
#: lib/mv_web/helpers/ash_error_helpers.ex
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "An error occurred"
@ -189,6 +210,11 @@ msgstr ""
msgid "Applicant data"
msgstr "Applicant data"
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Apply filters"
msgstr ""
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
@ -463,6 +489,11 @@ msgstr ""
msgid "City"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Clear filters"
msgstr ""
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
@ -473,6 +504,11 @@ msgstr "Click for details"
msgid "Click for group details"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for member details"
msgstr ""
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for role details"
@ -514,6 +550,11 @@ msgstr ""
msgid "Client Secret"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Close"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
@ -775,6 +816,11 @@ msgstr ""
msgid "Current amount"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Current payment cycle"
msgstr "Current payment cycle"
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom"
@ -785,6 +831,11 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom date fields"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field"
@ -864,6 +915,11 @@ msgstr ""
msgid "Date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Dates"
msgstr ""
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Deactivate"
@ -1240,12 +1296,22 @@ msgstr ""
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date to"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
@ -1404,7 +1470,6 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee type"
@ -1420,6 +1485,11 @@ msgstr ""
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Fee types"
msgstr "Fee types"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee types could not be loaded."
@ -1436,6 +1506,11 @@ msgstr ""
msgid "Fields on the join form"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Filter members"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -1455,7 +1530,7 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
@ -1582,6 +1657,7 @@ msgid "Group saved successfully."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
@ -1696,6 +1772,11 @@ msgstr ""
msgid "Inactive members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Inactive only"
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
@ -1731,6 +1812,7 @@ msgstr ""
msgid "Individual Datafields"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Individual datafields"
@ -1823,11 +1905,21 @@ msgstr ""
msgid "Join confirmation"
msgstr ""
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date from"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date to"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
@ -1917,6 +2009,11 @@ msgstr ""
msgid "Last name"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last payment cycle"
msgstr "Last payment cycle"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
@ -2011,7 +2108,6 @@ msgstr ""
msgid "Mark as unpaid"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
@ -2058,6 +2154,11 @@ msgstr ""
msgid "Member field %{action} successfully"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member filter"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
@ -2143,6 +2244,7 @@ msgstr ""
msgid "Membership Fee Start Date"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status"
@ -2187,12 +2289,14 @@ msgstr ""
msgid "Membership fee start"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type deleted"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type not found"
msgstr ""
@ -2247,14 +2351,12 @@ msgstr ""
#: 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/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_type_live/form.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/index.html.heex
#: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -2305,8 +2407,7 @@ msgstr ""
msgid "New amount"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@ -2328,6 +2429,11 @@ msgstr ""
msgid "No approved or rejected requests yet"
msgstr "No approved or rejected requests yet"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No cycle"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
@ -2587,10 +2693,11 @@ msgid "Options"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -2622,6 +2729,16 @@ msgstr ""
msgid "Payment Interval"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Payment Status"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
#: lib/mv_web/live/join_request_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
@ -2720,6 +2837,11 @@ msgstr ""
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr ""
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Reactivate member"
@ -3139,6 +3261,11 @@ msgstr ""
msgid "Set Password"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully."
@ -3167,6 +3294,11 @@ msgstr ""
msgid "Show in overview"
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
#, elixir-autogen, elixir-format
msgid "Sign in"
@ -3260,11 +3392,10 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
msgstr ""
@ -3469,7 +3600,7 @@ msgstr ""
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
@ -3569,10 +3700,11 @@ msgid "Unlinking scheduled"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
msgstr ""
@ -3772,8 +3904,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@ -3825,7 +3956,8 @@ msgstr ""
msgid "You do not have permission to %{action} members."
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this membership fee type"
msgstr ""
@ -3841,7 +3973,8 @@ msgstr ""
msgid "You do not have permission to delete this member"
msgstr ""
#: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type"
msgstr ""
@ -3946,7 +4079,6 @@ msgid "email %{email} has already been taken"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "filtered"
msgstr ""
@ -3997,6 +4129,11 @@ msgstr ""
msgid "updated"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format
msgid "without %{name}"
msgstr "without %{name}"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
@ -4008,487 +4145,3 @@ msgstr "Open"
msgctxt "status"
msgid "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 ""

View file

@ -1,32 +0,0 @@
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

View file

@ -1,21 +0,0 @@
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

View file

@ -1,22 +0,0 @@
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

View file

@ -1,148 +0,0 @@
{
"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"
}

View file

@ -1,173 +0,0 @@
{
"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"
}

View file

@ -1,206 +0,0 @@
{
"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"
}

View file

@ -1,98 +0,0 @@
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

View file

@ -0,0 +1,660 @@
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

View file

@ -2,28 +2,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
use MvWeb.ConnCase, async: true
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
test "renders with correct attributes", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that the component renders with correct attributes
assert has_element?(view, "[data-testid='first_name']")
@ -32,8 +14,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders all sortable headers", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
sortable_fields = [
:first_name,
@ -52,8 +34,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders correct labels", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test specific labels
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
@ -64,8 +46,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The neutral icon has the opcity class we can test for
# Test that EMAIL field specifically shows neutral icon
@ -76,10 +58,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# Test that FIRST_NAME field specifically shows ascending icon
# Test CSS classes - no opacity for active state
@ -99,10 +79,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, _view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending sort icon. Dropdown
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
@ -113,10 +91,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# CITY field should be active (ascending)
refute has_element?(view, "[data-testid='city'] .opacity-40")
@ -132,8 +108,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start: all fields neutral except first name as default
assert has_element?(view, "[data-testid='city'] .opacity-40")
@ -160,31 +136,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
conn = conn_with_oidc_user(conn)
# Test EMAIL field specifically
{:ok, view, html_asc} =
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc")
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
assert html_asc =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_desc} =
live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
assert html_desc =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}")
{:ok, view, html_neutral} = live(conn, "/members")
assert html_neutral =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
conn = conn_with_oidc_user(conn)
# Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
{:ok, _view, html_neutral} = live(conn, "/members")
# Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
@ -194,8 +166,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
# Test descending state
{:ok, _view, html_desc} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = active_sort_down_count(html_desc)
@ -218,38 +189,32 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Check aria-label for unsorted state
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check aria-label for ascending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
# Check aria-label for descending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
end
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check that tooltip div exists with correct data-tip
assert has_element?(view, "[data-testid='first_name']")
@ -257,8 +222,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Test aria-labels for different fields
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
@ -274,8 +239,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "component behavior" do
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on the first name sort header
view
@ -287,8 +252,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "component handles different field types correctly", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that different field types render correctly
assert has_element?(view, "button[phx-value-field='first_name']")
@ -299,10 +264,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
# Should not crash and should default sorting for first name
assert html =~ "hero-chevron-up-down"
@ -310,10 +273,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
# Should default to ascending
assert html =~ "hero-chevron-up"
@ -321,8 +282,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles empty sort parameters", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
# Should show neutral icons
assert html =~ "hero-chevron-up-down"
@ -332,8 +293,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start with neutral state
assert has_element?(view, "[data-testid='city'] .opacity-40")
@ -348,8 +309,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Email should be active (descending)
assert html =~ "hero-chevron-down"

View file

@ -113,74 +113,6 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
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
check all(
join_date <- join_date_state_gen(),

View file

@ -1,22 +0,0 @@
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

View file

@ -1,90 +0,0 @@
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

View file

@ -6,55 +6,247 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
alias MvWeb.MemberLive.Index.FieldVisibility
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
test "compact member + compact address: Name, E-Mail, Adresse lead" do
result = FieldVisibility.offered_member_fields(true, false, true)
assert Enum.take(result, 3) == [:name, :email, :address]
# 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)
# Mock custom field structs for testing
defp create_custom_field(id, name, show_in_overview \\ true) do
%{
id: id,
name: name,
show_in_overview: show_in_overview
}
end
test "compact member with email folded in: no separate E-Mail column" do
result = FieldVisibility.offered_member_fields(true, true, true)
assert Enum.take(result, 2) == [:name, :address]
refute :email in result
end
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
result = FieldVisibility.offered_member_fields(false, false, true)
# the reported bug: the composite address must not jump ahead of the
# expanded name constituents
assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
assert index_of(result, :address) < index_of(result, :join_date)
end
test "non-compact member + non-compact address: name, email, then the address block, then rest" do
result = FieldVisibility.offered_member_fields(false, false, false)
assert Enum.take(result, 7) == [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
describe "get_all_available_fields/1" do
test "returns member fields and custom fields" do
custom_fields = [
create_custom_field("cf1", "Custom Field 1"),
create_custom_field("cf2", "Custom Field 2")
]
# join_date and later Datenfelder columns never precede the address block
assert index_of(result, :city) < index_of(result, :join_date)
refute :name in result
refute :address in result
result = FieldVisibility.get_all_available_fields(custom_fields)
# 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
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
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 "custom_field_123" in result
end
test "converts member field strings to atoms" do
selection = %{"first_name" => true, "email" => true}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :email in result
end
test "keeps custom fields as strings" do
selection = %{"custom_field_abc-123" => true}
result = FieldVisibility.get_visible_fields(selection)
assert "custom_field_abc-123" in result
end
test "handles empty selection" do
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
describe "get_visible_member_fields/1" do
test "returns only member fields that are visible" do

View file

@ -1,135 +0,0 @@
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

View file

@ -1,109 +0,0 @@
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

View file

@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.MembershipFeeStatus
require Ash.Query
@ -25,6 +26,35 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
member
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
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
@ -309,5 +339,27 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
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

View file

@ -1,81 +0,0 @@
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

View file

@ -1,156 +0,0 @@
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

View file

@ -1,115 +0,0 @@
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

View file

@ -1,87 +0,0 @@
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

View file

@ -1,78 +0,0 @@
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

View file

@ -1,83 +0,0 @@
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

View file

@ -1,71 +0,0 @@
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

View file

@ -1,91 +0,0 @@
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

View file

@ -1,76 +0,0 @@
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

View file

@ -206,20 +206,14 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(
conn,
"/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc"
)
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
# Click on email column
view
|> element("[data-testid='email']")
|> render_click()
# 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"
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
end
test "clicking custom field column after regular column works", %{

View file

@ -1,91 +0,0 @@
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

View file

@ -1,66 +0,0 @@
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

View file

@ -1,51 +0,0 @@
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

View file

@ -1,74 +0,0 @@
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

View file

@ -1,65 +0,0 @@
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

View file

@ -1,73 +0,0 @@
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

View file

@ -1,82 +0,0 @@
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

View file

@ -22,17 +22,6 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
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
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -123,9 +112,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end
test "displays all member fields in dropdown", %{conn: conn} do
# 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()
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
@ -161,23 +148,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The curated Name column carries the member names, so they are visible.
# Verify email is visible initially
html = render(view)
assert html =~ "Anderson"
assert html =~ "alice@example.com"
# Open dropdown and hide the Name column
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# The Name column (and the names it carries) is no longer visible
# Email should no longer be visible
html = render(view)
refute html =~ "Anderson"
refute html =~ "Brown"
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
@ -311,8 +298,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# The curated Name and Address columns are visible by default.
assert html =~ "Anderson"
# All fields should be visible by default
assert html =~ "alice@example.com"
assert html =~ "Main St"
end
@ -322,17 +309,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide the curated Name column (which carries the names) via dropdown
# Hide a field via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
html = render(view)
refute html =~ "Anderson"
refute html =~ "alice@example.com"
end
end
@ -341,16 +328,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings (curated Name column visible)
assert html =~ "Anderson"
# Should fall back to global settings
assert html =~ "alice@example.com"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults (curated Name column visible)
assert html =~ "Anderson"
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
end
test "handles custom field that doesn't exist", %{conn: conn} do
@ -442,23 +429,23 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The curated Name column carries the names, so they are visible initially.
# Verify email is visible initially
html = render(view)
assert html =~ "Anderson"
assert html =~ "alice@example.com"
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Simulate Enter key press on the Name field button
# Simulate Enter key press on email field button
view
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_keydown(%{key: "Enter"})
# The Name column (and the names it carries) is no longer visible
# Email should no longer be visible
html = render(view)
refute html =~ "Anderson"
refute html =~ "alice@example.com"
end
end
end

View file

@ -1,92 +0,0 @@
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

View file

@ -1,50 +0,0 @@
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

View file

@ -1,257 +0,0 @@
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

View file

@ -1,48 +0,0 @@
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

View file

@ -1,38 +0,0 @@
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
@moduledoc """
§2.1 (extended) the v2 filter keys survive the LiveView decodeencode 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

View file

@ -1,72 +0,0 @@
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

View file

@ -65,22 +65,21 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
end
@tag :ui
test "field picker exposes a combobox with a Group field option", %{
test "filter dropdown has group presence section with legend", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open the add-filter picker (combobox + listbox of grouped fields)
open_picker(view)
# Open filter dropdown
view
|> element("button[aria-label='Filter members']")
|> render_click()
assert has_element?(view, "[data-testid='field-combobox'][role='combobox']")
assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
assert has_element?(view, "#member-filter-field-opt-group[role='option']")
# 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']")
html = render(view)
# Groups section: legend "Member has groups" and radios (Any / Yes / No)
assert html =~ ~r/[Gg]roups/
assert has_element?(view, "[data-testid='member-filter-form']")
end
@tag :ui
@ -103,7 +102,13 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
apply_group_filter(view, group1.id, "in")
view
|> 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)
assert html =~ member1.first_name
@ -135,7 +140,13 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
apply_group_filter(view, group1.id, "in")
view
|> 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)
assert html =~ member1.first_name

View file

@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
Tests for filtering members by group in the member overview.
Uses the filter dropdown (MemberFilterComponent) with one row per group:
All / Yes / No (per group). Multiple "is" selections combine with OR (union),
while each "is not" selection excludes members in that group (§1.29/§2.7).
All / Yes / No (per group). Multiple active group filters combine with AND
(member must match all selected group conditions).
"""
# Kept async: false as a deferred scope decision. The deferrable-FK migration
# removed the concurrent-create_member deadlock that previously forced this, so
@ -62,7 +62,20 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
end
defp open_filter_and_set_group(view, group_id, value) do
path = apply_group_filter(view, group_id, value)
view
|> 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}
end

View file

@ -103,7 +103,13 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
apply_group_filter(view, group1.id, "in")
view
|> 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)
assert html =~ member1.first_name
@ -168,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} =
live(conn, "/members?group_#{group1.id}=in&pay_filter=fully_paid")
live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
assert html =~ "Members"
# member1 has a group and no unpaid cycles; both filters keep it visible.
# member1 has a group and a paid cycle; page should load with both filters
assert html =~ member1.first_name
end
@ -185,7 +191,13 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
apply_group_filter(view, group1.id, "in")
view
|> 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;
# search form is in SearchBarComponent with phx-submit="search")
@ -230,7 +242,13 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
apply_group_filter(view, group1.id, "in")
view
|> 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
view

View file

@ -102,7 +102,13 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
{:ok, view, _html} = live(conn, "/members")
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
apply_group_filter(view, group1.id, "in")
view
|> 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, ...}
html = render(view)

View file

@ -20,16 +20,6 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
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
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -73,7 +63,13 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
apply_group_filter(view, group1.id, "in")
view
|> 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)
assert html =~ member1.first_name
@ -126,13 +122,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member1: member1,
group1: group1
} do
conn = conn |> conn_with_oidc_user() |> non_compact()
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
live(
conn,
"/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in"
)
live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")

View file

@ -1,86 +0,0 @@
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

View file

@ -1,68 +0,0 @@
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

View file

@ -38,31 +38,27 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{: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
assert html =~ field
end
end
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.
test "respects show_in_overview config", %{conn: conn, member1: m} do
{:ok, settings} = Mv.Membership.get_settings()
fields_to_hide = [:street, :house_number]
{:ok, _} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: %{"join_date" => false}
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
})
conn = conn_with_oidc_user(conn)
# Use search query to filter to only the expected member (Alice)
# 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")
refute has_element?(view, "[data-testid='join_date']")
# The composite Name column (and its email content) is unaffected.
assert has_element?(view, "[data-testid='member-name']")
assert html =~ "Email"
assert html =~ m.email
refute html =~ m.street
end
end

View file

@ -1,15 +1,16 @@
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
@moduledoc """
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.
Tests for membership fee status column in member list view.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
# Helper to create a member
defp create_member(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -25,135 +26,250 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
member
end
describe "payment aging column" do
test "header names the payment column", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Fees"
end
test "a member with unpaid cycles shows the unpaid count; suspended excluded", %{conn: conn} do
describe "status column display" do
test "shows status column in member list", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "OwesTwo", membership_fee_type_id: fee_type.id})
member = create_member(%{membership_fee_type_id: fee_type.id})
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})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
{:ok, _view, html} = live(conn, "/members")
assert html =~ "2 open"
refute html =~ "3 open"
# Should show membership fee status column
assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
end
test "a member with no unpaid cycles reads Paid", %{conn: conn} do
test "shows last completed cycle status by default", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "AllPaid", membership_fee_type_id: fee_type.id})
member = create_member(%{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[2022-01-01],
status: :paid,
replace_existing: true
})
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Paid"
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})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
badge = "a[data-testid='payment-badge']#payment-badge-#{member.id}"
href = "/members/#{member.id}?tab=membership_fees"
# 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]")
# Should show unpaid status (2023 is last completed)
html = render(view)
assert html =~ "hero-x-circle" || html =~ "unpaid"
end
test "the tooltip lists suspended cycles in a separate labeled section (§1.14)", %{conn: conn} do
test "toggle switches to current cycle view", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
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})
create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
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")
tip = "#payment-tip-#{member.id}"
# Toggle to current cycle (use the button in the header)
view
|> element("[data-testid=toggle-cycle-view]")
|> render_click()
# Suspended cycles are shown separately, under their own text label — the
# distinction is not color-only. The suspended cycle's period lives in the
# 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"
html = render(view)
# Should show suspended status (current cycle)
assert html =~ "hero-pause-circle" || html =~ "suspended"
end
test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do
test "shows correct color coding for paid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
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
})
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "a[data-testid='payment-badge']#payment-badge-#{member.id}")
refute has_element?(view, "#payment-tip-#{member.id}")
refute has_element?(view, "a#payment-badge-#{member.id}[phx-hook]")
html = render(view)
assert html =~ "text-success" || html =~ "hero-check-circle"
end
test "members without cycles render without error", %{conn: conn} do
test "shows correct color coding for unpaid status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{first_name: "NoCycles", membership_fee_type_id: fee_type.id})
member = create_member(%{membership_fee_type_id: fee_type.id})
{:ok, _view, html} = live(conn, "/members")
create_cycle(member, fee_type, %{
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
end
end
describe "payment filter" do
setup do
describe "filters" do
test "filter unpaid in last cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
unpaid = create_member(%{first_name: "HasUnpaid", membership_fee_type_id: fee_type.id})
create_cycle(unpaid, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Member with unpaid last cycle
member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
paid = create_member(%{first_name: "FullyPaid", membership_fee_type_id: fee_type.id})
create_cycle(paid, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
create_cycle(member1, fee_type, %{
cycle_start: ~D[2023-01-01],
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
test "has-unpaid ≥ 1 selects only members with unpaid cycles", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members?pay_filter=unpaid_1")
test "filter unpaid in current cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
assert html =~ "HasUnpaid"
refute html =~ "FullyPaid"
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
# 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
test "fully-paid selects only members with zero unpaid cycles", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
@moduletag :slow
describe "performance" do
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
assert html =~ "FullyPaid"
refute html =~ "HasUnpaid"
# Create multiple members with cycles
Enum.each(1..5, fn _ ->
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

View file

@ -1,98 +0,0 @@
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

View file

@ -1,96 +0,0 @@
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

View file

@ -1,82 +0,0 @@
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

View file

@ -1,79 +0,0 @@
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

Some files were not shown because too many files have changed in this diff Show more