diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2a57d3..af124bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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 diff --git a/assets/css/app.css b/assets/css/app.css index 611e9ad1..f8553c55 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -678,6 +678,78 @@ /* 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 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 ============================================ */ @@ -773,3 +845,130 @@ [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; +} + diff --git a/assets/js/app.js b/assets/js/app.js index a003e279..04842115 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,6 +22,8 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import Sortable from "../vendor/sortable" +import FilterComboboxSearch from "./hooks/filter_combobox_search" +import HoverPopover from "./hooks/hover_popover" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") @@ -36,6 +38,27 @@ 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() { @@ -126,6 +149,29 @@ Hooks.RowSelectionGuard = { } } +// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width +// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar +// lives in a full-width table row that is wider than the viewport when the table scrolls +// horizontally; matching the sticky element to the container's visible width keeps its +// centered content pinned at the bottom-center of the visible area instead of drifting off +// with the scrolled table. The data-scroll-container attribute names the container element id. +Hooks.StickyViewportWidth = { + mounted() { + this.container = document.getElementById(this.el.dataset.scrollContainer) + this.sync = () => { + if (this.container) this.el.style.width = this.container.clientWidth + "px" + } + this.sync() + window.addEventListener("resize", this.sync) + }, + updated() { + this.sync() + }, + destroyed() { + window.removeEventListener("resize", this.sync) + } +} + // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { @@ -313,6 +359,72 @@ Hooks.SortableList = { } } +// SortTooltip hook: shows the sort-header hint in the browser TOP LAYER via the +// native Popover API, so it escapes the members table's overflow clipping +// (overflow-x:auto forces overflow-y:auto, which clips a CSS tooltip below the +// header) and paints above the sticky header, neighbor cells, and the pinned +// checkbox column's fade — no z-index fight. The popover is shown on hover AND +// keyboard focus and positioned next to the header with getBoundingClientRect +// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox). +Hooks.SortTooltip = { + mounted() { + this.tip = document.getElementById(this.el.dataset.tooltipId) + // Feature-detect: without Popover API support fall back to no visual tooltip + // (the button's aria-label still conveys the hint to assistive tech). + if (!this.tip || typeof this.tip.showPopover !== "function") return + + this.position = () => { + const btn = this.el.getBoundingClientRect() + const tip = this.tip.getBoundingClientRect() + const gap = 6 + // Prefer just below the header; flip above when there is no room below. + let top = btn.bottom + gap + if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) { + top = btn.top - gap - tip.height + } + // Left-align to the header, but keep the whole bubble on-screen. + let left = btn.left + const maxLeft = window.innerWidth - tip.width - gap + if (left > maxLeft) left = maxLeft + if (left < gap) left = gap + this.tip.style.top = `${Math.round(top)}px` + this.tip.style.left = `${Math.round(left)}px` + } + + this.show = () => { + try { + if (!this.tip.matches(":popover-open")) this.tip.showPopover() + // showPopover() makes it measurable; positioning in the same tick avoids + // a visible flash (JS runs before the browser paints). + this.position() + } catch (_e) {} + } + + this.hide = () => { + try { + if (this.tip.matches(":popover-open")) this.tip.hidePopover() + } catch (_e) {} + } + + this.onKey = (e) => { if (e.key === "Escape") this.hide() } + + this.el.addEventListener("mouseenter", this.show) + this.el.addEventListener("focus", this.show) + this.el.addEventListener("mouseleave", this.hide) + this.el.addEventListener("blur", this.hide) + this.el.addEventListener("keydown", this.onKey) + }, + + destroyed() { + if (this.hide) this.hide() + this.el.removeEventListener("mouseenter", this.show) + this.el.removeEventListener("focus", this.show) + this.el.removeEventListener("mouseleave", this.hide) + this.el.removeEventListener("blur", this.hide) + this.el.removeEventListener("keydown", this.onKey) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { @@ -360,15 +472,77 @@ Hooks.SidebarState = { } } +// LoadMorePrefetch: fires an infinite-scroll load event as the sentinel +// approaches the viewport, rather than only once it is fully scrolled into view. +// An IntersectionObserver rooted at the scroll container with a positive bottom +// rootMargin treats the sentinel as visible while it is still that many pixels +// below the fold, so the next page loads ahead of time. It complements the +// built-in phx-viewport-bottom binding (which stays as a reliable backstop); +// duplicate loads are harmless because the server guards on "more?" and the +// stream de-duplicates rows by id. +Hooks.LoadMorePrefetch = { + mounted() { + const container = document.getElementById(this.el.dataset.scrollContainer) + const rootMargin = `0px 0px ${this.el.dataset.margin || "600px"} 0px` + const event = this.el.dataset.event || "load_more" + let lastPush = 0 + + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + const now = Date.now() + if (now - lastPush < 400) return + lastPush = now + this.pushEvent(event) + }, {root: container || null, rootMargin, threshold: 0}) + + this.observer.observe(this.el) + }, + + destroyed() { + if (this.observer) this.observer.disconnect() + } +} + +// Reads a browser cookie value by name (used to echo the persisted member +// view-settings to the server on connect, since the live socket's connect-info +// does not expose cookies). +function getCookie(name) { + const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")) + return match ? decodeURIComponent(match[1]) : null +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, - timezone: getBrowserTimezone() + timezone: getBrowserTimezone(), + view_settings: getCookie("member_view_settings") }, 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 diff --git a/assets/js/hooks/filter_combobox_search.js b/assets/js/hooks/filter_combobox_search.js new file mode 100644 index 00000000..ec60061b --- /dev/null +++ b/assets/js/hooks/filter_combobox_search.js @@ -0,0 +1,33 @@ +// FilterComboboxSearch hook: the type-ahead search input of the add-filter +// field picker (§1.21). +// +// Responsibilities: +// * Autofocus the input when the picker opens, so the user can type +// immediately (the cursor lands in the search field on mount). Programmatic +// .focus() does NOT trigger the browser's :focus-visible state, so no focus +// ring is drawn on open — the ring appears only during keyboard navigation, +// which is exactly the desired behaviour (focus-visible on keyboard only). +// * Guard Enter: while the listbox is open, Enter must not submit the +// surrounding form (this replaces the old standalone ComboBox hook). +// +// The actual field-list filtering is done server-side (phx-change pushes the +// query; the component narrows the descriptor list), so this hook only manages +// focus and the Enter guard. +const FilterComboboxSearch = { + mounted() { + // Defer to the next frame so focus lands after the dropdown is laid out. + requestAnimationFrame(() => this.el.focus()) + + this.handleKeyDown = (e) => { + const open = this.el.getAttribute("aria-expanded") === "true" + if (e.key === "Enter" && open) e.preventDefault() + } + this.el.addEventListener("keydown", this.handleKeyDown) + }, + + destroyed() { + this.el.removeEventListener("keydown", this.handleKeyDown) + } +} + +export default FilterComboboxSearch diff --git a/assets/js/hooks/hover_popover.js b/assets/js/hooks/hover_popover.js new file mode 100644 index 00000000..71a80cdc --- /dev/null +++ b/assets/js/hooks/hover_popover.js @@ -0,0 +1,76 @@ +// HoverPopover hook: opens a native Popover-API element on hover/keyboard focus +// of its trigger, so the popover renders in the browser TOP LAYER and escapes +// any ancestor `overflow` clipping (the members table forces overflow-y:auto, +// which clips an absolutely-positioned daisyUI tooltip/dropdown-content). Unlike +// the older SortTooltip, placement is handed to CSS anchor positioning +// (`anchor-name` on the trigger, `position-anchor`/`position-area` on the +// popover) — this hook does no coordinate math, it only toggles visibility. +// +// WCAG 2.2 AA / SC 1.4.13 (Content on Hover or Focus): the popover is shown on +// both hover and focus, is dismissible via Escape, and is hoverable/persistent — +// a short close delay plus the popover's own hover keep it open while the pointer +// travels from the trigger onto the popover content. +// +// Expected DOM: +// +//
+const HoverPopover = { + mounted() { + this.tip = document.getElementById(this.el.dataset.popoverTarget) + // Feature-detect: without Popover API support fall back to no visual popover + // (the trigger's aria-label still conveys the summary to assistive tech). + if (!this.tip || typeof this.tip.showPopover !== "function") return + + this.open = () => { + try { + if (!this.tip.matches(":popover-open")) this.tip.showPopover() + } catch (_e) {} + } + this.close = () => { + try { + if (this.tip.matches(":popover-open")) this.tip.hidePopover() + } catch (_e) {} + } + this.cancelClose = () => { + if (this.closeTimer) { + clearTimeout(this.closeTimer) + this.closeTimer = null + } + } + this.show = () => { + this.cancelClose() + this.open() + } + this.scheduleClose = () => { + this.cancelClose() + this.closeTimer = setTimeout(() => this.close(), 120) + } + this.onKey = (e) => { + if (e.key === "Escape") this.close() + } + + this.el.addEventListener("mouseenter", this.show) + this.el.addEventListener("focus", this.show) + this.el.addEventListener("mouseleave", this.scheduleClose) + this.el.addEventListener("blur", this.scheduleClose) + this.el.addEventListener("keydown", this.onKey) + // Keep it open while the pointer is over the popover itself (hoverable). + this.tip.addEventListener("mouseenter", this.show) + this.tip.addEventListener("mouseleave", this.scheduleClose) + }, + + destroyed() { + if (!this.tip) return + this.cancelClose() + this.close() + this.el.removeEventListener("mouseenter", this.show) + this.el.removeEventListener("focus", this.show) + this.el.removeEventListener("mouseleave", this.scheduleClose) + this.el.removeEventListener("blur", this.scheduleClose) + this.el.removeEventListener("keydown", this.onKey) + this.tip.removeEventListener("mouseenter", this.show) + this.tip.removeEventListener("mouseleave", this.scheduleClose) + } +} + +export default HoverPopover diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 623455d6..91404aad 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -48,6 +48,13 @@ 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": }'`) 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 @@ -133,6 +140,16 @@ 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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0f67a00d..b0ff298f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -266,6 +266,18 @@ 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 @@ -279,27 +291,7 @@ defmodule Mv.Membership.Member do threshold = Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_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 + apply_fuzzy_search_filter(query, q, threshold) end end @@ -725,6 +717,25 @@ defmodule Mv.Membership.Member do constraints one_of: [:unpaid, :paid, :suspended] end + calculate :unpaid_cycle_count, + :integer, + expr( + count(membership_fee_cycles, + query: [ + filter: + expr( + status == :unpaid and + (is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and + (is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to)) + ) + ] + ) + ) do + description "Count of the member's unpaid cycles whose cycle_end is within the given period" + argument :period_from, :date, allow_nil?: true + argument :period_to, :date, allow_nil?: true + end + calculate :overdue_count, :integer do description "Count of unpaid cycles that have already ended (cycle_end < today)" # Automatically load cycles with all attributes and membership_fee_type @@ -738,6 +749,13 @@ 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] @@ -1212,6 +1230,43 @@ 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 # ============================================================================ diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2a5b17fb..d1295c6b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -44,7 +44,10 @@ 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 diff --git a/lib/membership_fees/cycle_end_backfill.ex b/lib/membership_fees/cycle_end_backfill.ex new file mode 100644 index 00000000..1d739dad --- /dev/null +++ b/lib/membership_fees/cycle_end_backfill.ex @@ -0,0 +1,53 @@ +defmodule Mv.MembershipFees.CycleEndBackfill do + @moduledoc """ + One-time backfill for the denormalized `cycle_end` column on + `membership_fee_cycles`. + + For every cycle whose `cycle_end` is still `NULL`, sets it to + `CalendarCycles.calculate_cycle_end(cycle_start, interval)` using the cycle's + own fee-type interval. Idempotent: rows that already have a `cycle_end` are + left untouched, so it is safe to re-run. + + Invoked from the `cycle_end` migration. Kept as a module function (rather than + inline migration SQL) so the exact calendar arithmetic is reused from + `CalendarCycles` and can be tested directly. + """ + + import Ecto.Query + + alias Mv.MembershipFees.CalendarCycles + alias Mv.Repo + + @doc """ + Backfills `cycle_end` for all cycles missing it. Returns `{:ok, updated_count}`. + """ + @spec run() :: {:ok, non_neg_integer()} + def run do + rows = + from(c in "membership_fee_cycles", + join: t in "membership_fee_types", + on: c.membership_fee_type_id == t.id, + where: is_nil(c.cycle_end), + select: {c.id, c.cycle_start, t.interval} + ) + |> Repo.all() + + count = + Enum.reduce(rows, 0, fn {id, cycle_start, interval}, acc -> + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, normalize_interval(interval)) + + from(c in "membership_fee_cycles", where: c.id == ^id) + |> Repo.update_all(set: [cycle_end: cycle_end]) + + acc + 1 + end) + + {:ok, count} + end + + # The raw query returns the enum column as a string. + defp normalize_interval(interval) when is_atom(interval), do: interval + + defp normalize_interval(interval) when is_binary(interval), + do: String.to_existing_atom(interval) +end diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index cd628871..d6842efe 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -13,7 +13,9 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do - `notes` - Optional notes for this cycle ## Design Decisions - - **No cycle_end field**: Calculated from cycle_start + interval (from fee type) + - **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type). + Because the fee type interval and the cycle_start are immutable after create, + cycle_end never drifts, so it can be filtered/sorted in SQL. - **Amount stored per cycle**: Preserves historical amounts when fee type changes - **Calendar-aligned cycles**: All cycles start on calendar boundaries @@ -34,6 +36,16 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do postgres do table "membership_fee_cycles" repo Mv.Repo + + custom_indexes do + # Serves the period-scoped unpaid-cycle count on the member overview: + # count of a member's cycles with status = :unpaid and cycle_end in the + # active period. The composite (member_id, status, cycle_end) lets the + # aggregate seek the per-member unpaid slice and range-scan cycle_end + # without touching paid/suspended rows. + index [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + end end resource do @@ -46,6 +58,14 @@ 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 @@ -106,6 +126,13 @@ 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 diff --git a/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex b/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex new file mode 100644 index 00000000..296f952c --- /dev/null +++ b/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex @@ -0,0 +1,68 @@ +defmodule Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd do + @moduledoc """ + Ash change that denormalizes `cycle_end` on a membership fee cycle at create time. + + `cycle_end` is computed as `CalendarCycles.calculate_cycle_end(cycle_start, interval)`, + where `interval` is read from the cycle's `membership_fee_type`. Because a fee type's + `interval` is immutable and the cycle's `cycle_start` is never updated, the stored + `cycle_end` is fixed at create time and never drifts. + + When the caller already knows the interval (e.g. the bulk cycle generator, which + resolves the fee type once for a whole batch), it may pass it via the `:interval` + create argument; the change then uses that value and issues no fee-type query, + avoiding an N+1 lookup across a batch of cycles. + + Otherwise the fee-type interval is read with `authorize?: false`: it is an internal + lookup of immutable reference data needed to populate a derived column, not a + user-facing read, and cycle creation is itself a systemic operation. Reading it under + the caller's actor is unreliable here (the actor is not always present in the change + context, e.g. when a changeset is built with `for_create/3` and the actor is only + supplied to `Ash.create/2`). + + When `cycle_start` or `membership_fee_type_id` are missing the changeset is returned + unchanged so the normal `allow_nil?` validations surface the error. + """ + use Ash.Resource.Change + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.MembershipFeeType + + @impl true + def change(changeset, _opts, _context) do + with {:ok, cycle_start} <- fetch(changeset, :cycle_start), + {:ok, fee_type_id} <- fetch(changeset, :membership_fee_type_id), + {:ok, interval} <- resolve_interval(changeset, fee_type_id) do + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval) + Ash.Changeset.force_change_attribute(changeset, :cycle_end, cycle_end) + else + _ -> changeset + end + end + + defp fetch(changeset, field) do + case Ash.Changeset.fetch_change(changeset, field) do + {:ok, value} when not is_nil(value) -> + {:ok, value} + + _ -> + case Map.get(changeset.data, field) do + nil -> :error + value -> {:ok, value} + end + end + end + + defp resolve_interval(changeset, fee_type_id) do + case Ash.Changeset.get_argument(changeset, :interval) do + nil -> fetch_interval(fee_type_id) + interval -> {:ok, interval} + end + end + + defp fetch_interval(fee_type_id) do + case Ash.get(MembershipFeeType, fee_type_id, authorize?: false) do + {:ok, %{interval: interval}} -> {:ok, interval} + _ -> :error + end + end +end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 657aa9b7..a0607078 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -38,6 +38,20 @@ defmodule Mv.Constants do @custom_date_filter_prefix "cdf_" + @payment_period_from_param "pay_from" + + @payment_period_to_param "pay_to" + + @payment_filter_param "pay_filter" + + @payment_count_min_param "pay_min" + + @payment_count_max_param "pay_max" + + @suspended_param "suspended" + + @as_of_date_param "as_of_date" + @max_boolean_filters 50 @max_mailto_bulk_recipients 50 @@ -162,6 +176,81 @@ defmodule Mv.Constants do """ def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ + Returns the URL parameter name for the payment-aging period lower bound + (ISO-8601 date; scopes both the payment column and the payment filter). + + ## Examples + + iex> Mv.Constants.payment_period_from_param() + "pay_from" + """ + def payment_period_from_param, do: @payment_period_from_param + + @doc """ + Returns the URL parameter name for the payment-aging period upper bound. + + ## Examples + + iex> Mv.Constants.payment_period_to_param() + "pay_to" + """ + def payment_period_to_param, do: @payment_period_to_param + + @doc """ + Returns the URL parameter name for the payment-count filter + (`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`). + + ## Examples + + iex> Mv.Constants.payment_filter_param() + "pay_filter" + """ + def payment_filter_param, do: @payment_filter_param + + @doc """ + Returns the URL parameter name for the payment open-cycle count lower bound + (used with `pay_filter=has_unpaid` for the two-sided count range). + + ## Examples + + iex> Mv.Constants.payment_count_min_param() + "pay_min" + """ + def payment_count_min_param, do: @payment_count_min_param + + @doc """ + Returns the URL parameter name for the payment open-cycle count upper bound. + + ## Examples + + iex> Mv.Constants.payment_count_max_param() + "pay_max" + """ + def payment_count_max_param, do: @payment_count_max_param + + @doc """ + Returns the URL parameter name for the suspended-status filter flag. + + ## Examples + + iex> Mv.Constants.suspended_param() + "suspended" + """ + def suspended_param, do: @suspended_param + + @doc """ + Returns the URL parameter name for the point-in-time "active on X" membership + filter: keeps members who were active on the given ISO-8601 date X, i.e. + `join_date <= X and (exit_date is nil or exit_date > X)`. + + ## Examples + + iex> Mv.Constants.as_of_date_param() + "as_of_date" + """ + def as_of_date_param, do: @as_of_date_param + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 9f848a3b..f1af5dbc 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -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, parsed.show_current_cycle) + |> maybe_load_cycles(need_cycles) |> 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, _show_current), do: query + defp maybe_load_cycles(query, false), do: query - defp maybe_load_cycles(query, true, show_current) do - MembershipFeeStatus.load_cycles_for_members(query, show_current) + defp maybe_load_cycles(query, true) do + MembershipFeeStatus.load_cycles_for_members(query) end defp maybe_load_groups(query, false), do: query diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index a6c2d300..8bd76d5e 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -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, opts) + create_cycles(cycle_starts, member.id, fee_type.id, amount, interval, 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, opts) do + defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do actor = get_actor(opts) create_opts = Helpers.ash_actor_opts(actor) @@ -414,7 +414,10 @@ defmodule Mv.MembershipFees.CycleGenerator do member_id: member_id, membership_fee_type_id: fee_type_id, amount: amount, - status: :unpaid + status: :unpaid, + # Pass the already-resolved interval so SetCycleEnd does not re-fetch the + # fee type for every cycle in the batch (avoids an N+1 lookup). + interval: interval } handle_cycle_creation_result( diff --git a/lib/mv_web/components/bulk_actions_dropdown.ex b/lib/mv_web/components/bulk_actions_dropdown.ex index d0b6172a..8d0e1509 100644 --- a/lib/mv_web/components/bulk_actions_dropdown.ex +++ b/lib/mv_web/components/bulk_actions_dropdown.ex @@ -1,6 +1,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do @moduledoc """ - Single "Aktionen" dropdown bundling the four member bulk actions, flattened to + Single "Actions" dropdown bundling the four member bulk actions, flattened to one level: open in email program (mailto), copy email addresses, export to CSV, export to PDF. @@ -10,10 +10,10 @@ defmodule MvWeb.Components.BulkActionsDropdown do ## Scope and trigger badge - The trigger reads `Aktionen` followed by a scope badge: an emphasized + The trigger reads the `Actions` label followed by a scope badge: an emphasized (`primary`) count `N` when `N` members are selected, and a muted (`neutral`) - 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 + 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 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,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do ## Event routing - `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. + `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. """ use MvWeb, :live_component use Gettext, backend: MvWeb.Gettext @@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do |> assign(:recipient_count, assigns[:recipient_count] || 0) |> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false) - # 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. + # 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. socket = case Map.fetch(assigns, :open) do {:ok, open} -> assign(socket, :open, open) - :error -> socket + :error -> assign_new(socket, :open, fn -> false end) end {:ok, socket} @@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do button_label={gettext("Actions")} icon="hero-bolt" open={@open} - phx_target={@myself} + phx_target={nil} menu_width="w-70" menu_align="left" button_class="btn-secondary gap-2" @@ -214,7 +216,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do gettext("Too many recipients for this function. Copy the addresses or export the list.") end - # The trigger scope is shown as a badge after the "Aktionen" label. Only an + # The trigger scope is shown as a badge after the "Actions" label. Only an # actual selection is emphasized (primary); both the "filtered" and "all" # scopes are muted (neutral), since neither means members are selected. defp scope_label(assigns) do @@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do end 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 + # Open/close are handled by the parent LiveView (see "Event routing"); the + # component no longer owns dropdown state. end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 2ed22fd1..ff74760d 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -143,12 +143,18 @@ 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", @@ -169,7 +175,9 @@ defmodule MvWeb.CoreComponents do base_class = Map.fetch!(variant_classes, variant) size_class = size_classes[size] - btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") + + btn_class = + [base_class, size_class, active_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") assigns = assigns @@ -214,6 +222,27 @@ 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""" + + """ + end + @doc """ Renders a non-interactive badge with WCAG-compliant contrast. @@ -250,7 +279,7 @@ defmodule MvWeb.CoreComponents do attr :size, :any, default: "md", - doc: "Badge size: sm | md" + doc: "Badge size: xs | sm | md" attr :sr_label, :string, default: nil, @@ -269,7 +298,7 @@ defmodule MvWeb.CoreComponents do variant_class = "badge-#{variant}" style_class = badge_style_class(style) - size_class = "badge-#{size}" + size_class = badge_size_class(size) # Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300 outline_bg = if style == "outline", do: "bg-base-100", else: nil @@ -309,6 +338,13 @@ defmodule MvWeb.CoreComponents do defp badge_style_class("outline"), do: "badge-outline" defp badge_style_class(_), 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). @@ -443,6 +479,11 @@ 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)" @@ -546,6 +587,19 @@ defmodule MvWeb.CoreComponents do > {gettext("None")} + @@ -938,6 +992,11 @@ 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: @@ -953,6 +1012,11 @@ 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 @@ -964,6 +1028,10 @@ 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 @@ -986,6 +1054,15 @@ 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"""
-
+
- - + - {if dyn_col[:render] do - rendered = dyn_col[:render].(@row_item.(row)) + <%= if dyn_col[:custom_field].value_type == :boolean do %> + <% val = dyn_col[:render] && dyn_col[:render].(@row_item.(row)) %> + <%= cond do %> + <% val == true -> %> +
+ <.icon + name="hero-check-circle" + class="size-4 text-success" + aria-hidden="true" + /> + {gettext("Yes")} +
+ <% val == false -> %> +
+ <.icon + name="hero-x-circle" + class="size-4 text-error" + aria-hidden="true" + /> + {gettext("No")} +
+ <% true -> %> + <.empty_cell sr_text={gettext("Not specified")} /> + <% end %> + <% else %> + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) - if rendered == "" do - "" + if rendered == "" do + "" + else + rendered + end else - rendered - end - else - "" - end} + "" + end} + <% end %> + <%!-- 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. --%> + + + + +
{col[:label]} + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -1021,16 +1109,24 @@ defmodule MvWeb.CoreComponents do
+ {render_slot(@footer)} +
""" @@ -1160,8 +1301,8 @@ defmodule MvWeb.CoreComponents do end # Combines column class with optional sticky header classes (desktop only; theme-friendly bg). - # hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10) - # so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background. + # 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. defp table_th_class(col, sticky_header) do base = Map.get(col, :class) sticky = if sticky_header, do: sticky_th_classes(), else: nil @@ -1173,7 +1314,7 @@ defmodule MvWeb.CoreComponents do defp table_th_sticky_class(_), do: nil defp sticky_th_classes, - do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20" + do: "lg:sticky lg:top-0 bg-base-100 z-10" @doc """ Renders a reorderable table (sortable list) with drag handle and keyboard support. diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index e33b2115..a7683b4a 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -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, parsed.show_current_cycle) + |> maybe_load_cycles(need_cycles) |> 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, _show_current), do: query + defp maybe_load_cycles(query, false), do: query - defp maybe_load_cycles(query, true, show_current) do - MembershipFeeStatus.load_cycles_for_members(query, show_current) + defp maybe_load_cycles(query, true) do + MembershipFeeStatus.load_cycles_for_members(query) end defp maybe_load_groups(query, false), do: query diff --git a/lib/mv_web/live/components/add_filter_builder_component.ex b/lib/mv_web/live/components/add_filter_builder_component.ex new file mode 100644 index 00000000..4cc2c619 --- /dev/null +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -0,0 +1,1355 @@ +defmodule MvWeb.Components.AddFilterBuilderComponent do + @moduledoc """ + The member-overview add-filter builder (replaces the vertical + `MemberFilterComponent`). + + It follows the Polaris-style add-filter flow (Option C, append-right): + + * a "+ Add filter" trigger opens a combobox **field picker** grouped + Quick / Membership / Custom fields (see `FilterDescriptor`), with a + focused, type-ahead search input (§1.21); + * picking a field closes the picker and opens a focused, type-aware **value + control** wrapped in a consistent popover shell titled "Filter: " + (§3.11); simple selects/radios commit on click, date-ranges have + Apply/Cancel (§3.13); + * applied filters render as **one compact chip per value**; the chip body is + an editable button that re-opens the value control pre-filled (§1.18) and a + separate × removes the filter; + * the "+ Add filter" trigger, the chips, and **Clear all** share one uniform + button size (§1.19). + + There is no "All"/"Alle" option in the value controls — a cleared filter is + simply the absence of a chip (§1.20). Multi-select group/fee-type values are + OR (`is`) / exclude-all (`is not`) (§1.29). + + Filter state itself lives in the parent LiveView assigns; this component is a + pure input layer. It emits the parent's existing filter messages unchanged: + + * `{:group_filter_changed, id_str, :in | :not_in | nil}` + * `{:fee_type_filter_changed, id_str, :in | :not_in | nil}` + * `{:boolean_filter_changed, id_str, true | false | nil}` + * `{:date_filters_changed, date_filters}` + + plus the period-scoped payment and status messages: + + * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}` + * `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}` + * `{:suspended_changed, boolean()}` + * `{:as_of_date_changed, Date.t() | nil}` + + and `{:clear_all_filters}` for Clear all. + """ + use MvWeb, :live_component + + alias Mv.Constants + alias MvWeb.Helpers.DateFormatter + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.DatePresets + alias MvWeb.MemberLive.Index.FilterDescriptor + alias MvWeb.MemberLive.Index.FilterParams + alias MvWeb.MemberLive.Index.PaymentAging + + @group_filter_prefix Constants.group_filter_prefix() + @fee_type_filter_prefix Constants.fee_type_filter_prefix() + @custom_date_filter_prefix Constants.custom_date_filter_prefix() + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:open_picker, false) + |> assign(:picker_query, "") + |> assign(:edit_anchor, nil) + |> assign(:pending, nil)} + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(:id, assigns.id) + |> assign_membership_inputs(assigns) + |> assign_extra_inputs(assigns)} + end + + defp assign_membership_inputs(socket, assigns) do + socket + |> assign(:groups, assigns[:groups] || []) + |> assign(:group_filters, assigns[:group_filters] || %{}) + |> assign(:fee_types, assigns[:fee_types] || []) + |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) + |> assign(:group_filter_prefix, @group_filter_prefix) + |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) + end + + defp assign_extra_inputs(socket, assigns) do + socket + |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) + |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) + |> assign(:date_custom_fields, assigns[:date_custom_fields] || []) + |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) + |> assign(:payment_filter, assigns[:payment_filter]) + |> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period()) + |> assign(:suspended, assigns[:suspended] || false) + |> assign(:as_of_date, assigns[:as_of_date]) + end + + # -------------------------------------------------------------------------- + # Render + # -------------------------------------------------------------------------- + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:chips, applied_chips(assigns)) + |> assign(:descriptors, descriptors(assigns)) + + ~H""" +
+
+ + + <%!-- Field picker: an anchored dropdown (absolute, top-layer via z-index) + so opening it never reflows the toolbar. Sized to content — narrow and + tall, capped at 70vh with the option list scrolling internally. --%> + +
+ + + + <%!-- Pending filter (add-flow only): the newly picked field renders a chip + anchor and its type-aware value control opens as an anchored dropdown below + it (absolute, top-layer), so the toolbar never reflows while choosing a + value. Editing an existing chip anchors under that chip instead (above). + Clicking the chip (or Escape / click-away) dismisses without committing. --%> +
+ +
+ {render_value_control(assigns)} +
+
+ + +
+ """ + end + + # --- unified popover shell (§3.11) ---------------------------------------- + + attr :title, :string, required: true + attr :wide, :boolean, default: false + slot :inner_block, required: true + + # Shared popover shell for every value control (§3.11): the same container, + # the same "Filter: " title, and a consistent min-width (wider for the + # date-range and multi-select controls). The control's own body — a + # `phx-change="commit"` form or the date-range disclosure — carries the + # `value-control-*` test id so tests target the element that also owns the + # change binding. + defp control_shell(assigns) do + ~H""" +
+
+ {gettext("Filter: %{field}", field: @title)} +
+ {render_slot(@inner_block)} +
+ """ + end + + # --- shared native date-range row ----------------------------------------- + + attr :from_name, :string, required: true + attr :to_name, :string, required: true + attr :from_value, :string, default: "" + attr :to_value, :string, default: "" + attr :testid, :string, default: nil + + # The single native "Von/Bis" range used by *every* date-range value control + # (payment period, join date, exit date, custom date fields). Two + # `` sit side by side in a non-wrapping `flex` row, each + # wrapped in `min-w-0 flex-1` so they share the row width equally and always + # stay on one line. Only the input `name`s differ between call sites, so the + # dispatch stays per-control while the render is identical everywhere. + defp date_range_row(assigns) do + ~H""" +
+
+ <.input + type="date" + name={@from_name} + label={gettext("From")} + class="input input-sm input-bordered w-full" + value={@from_value} + /> +
+
+ <.input + type="date" + name={@to_name} + label={gettext("To")} + class="input input-sm input-bordered w-full" + value={@to_value} + /> +
+
+ """ + end + + # --- value controls ------------------------------------------------------- + + defp render_value_control(%{pending: %{control: :payment_count}} = assigns) do + ~H""" + <.control_shell title={@pending.label} wide={true}> +
+ +
+ {gettext("Payment status")} +
+ <.toggle_option name="pay" value="fully_paid" checked={@payment_filter == :fully_paid}> + {gettext("All paid")} + + <.toggle_option name="pay" value="has_unpaid" checked={has_unpaid?(@payment_filter)}> + {gettext("Open contributions")} + +
+
+ +
+ {gettext("Open cycles")} +
+ <.input + type="number" + name="pay_min" + label={gettext("min")} + min="1" + class="input input-sm input-bordered w-20" + value={range_min(@payment_filter)} + /> + <.input + type="number" + name="pay_max" + label={gettext("max")} + min="1" + class="input input-sm input-bordered w-20" + value={range_max(@payment_filter)} + /> +
+
+ + + +
+ {gettext("Contribution period")} + <.date_range_row + from_name="pay_from" + to_name="pay_to" + from_value={date_iso(@payment_period[:from]) || ""} + to_value={date_iso(@payment_period[:to]) || ""} + testid="payment-period-range" + /> +
+
+ + """ + end + + defp render_value_control(%{pending: %{control: :active_former}} = assigns) do + ~H""" + <.control_shell title={@pending.label} wide={true}> +
+
+ {gettext("Active / former")} +
+ <.toggle_option name="quick" value="active" checked={quick(@date_filters) == :active}> + {gettext("Active")} + + <.toggle_option name="quick" value="former" checked={quick(@date_filters) == :former}> + {gettext("Former")} + + <.toggle_option name="quick" value="all" checked={quick(@date_filters) == :all}> + {gettext("All")} + +
+
+ + <%!-- Separate paragraph (not part of the Active/Former button-join): the + point-in-time "active on X" as-of-date filter (§1.24). --%> +
+ <.input + type="date" + name="as_of_date" + label={gettext("Active on reference date")} + class="input input-sm input-bordered" + value={date_iso(@as_of_date)} + data-testid="as-of-date-input" + /> +
+
+ + <%!-- Third paragraph, a custom range: the same native From/To range the + join-date builder uses, wired to the exit_date custom mode — members whose + exit date falls within [from, to] (§1.24). A sibling form (not the + quick/as-of-date one) so committing a bound only touches the exit_date + slice and never re-reads the quick radios. --%> +
+ {gettext("Custom range")} + <.date_range_row + from_name="ed_from" + to_name="ed_to" + from_value={exit_bound_iso(@date_filters, :from)} + to_value={exit_bound_iso(@date_filters, :to)} + testid="exit-custom-range" + /> +
+ + """ + end + + defp render_value_control(%{pending: %{control: :group_membership}} = assigns) do + ~H""" + <.control_shell title={@pending.label} wide={true}> +
+
+ {group.name} +
+ <.in_not_in_radios + name={"#{@group_filter_prefix}#{group.id}"} + current={Map.get(@group_filters, to_string(group.id))} + /> +
+
+
+ + """ + end + + defp render_value_control(%{pending: %{control: :fee_type_membership}} = assigns) do + ~H""" + <.control_shell title={@pending.label} wide={true}> +
+
+ {ft.name} +
+ <.in_not_in_radios + name={"#{@fee_type_filter_prefix}#{ft.id}"} + current={Map.get(@fee_type_filters, to_string(ft.id))} + /> +
+
+
+ + """ + end + + defp render_value_control(%{pending: %{control: :boolean, key: key}} = assigns) do + assigns = assign(assigns, :field_id, to_string(key)) + + ~H""" + <.control_shell title={@pending.label}> +
+
+ {@pending.label} +
+ <.toggle_option + name={"custom_boolean[#{@field_id}]"} + value="true" + icon="hero-check-circle" + checked={Map.get(@boolean_filters, @field_id) == true} + > + {gettext("Yes")} + + <.toggle_option + name={"custom_boolean[#{@field_id}]"} + value="false" + icon="hero-x-circle" + checked={Map.get(@boolean_filters, @field_id) == false} + > + {gettext("No")} + +
+
+
+ + """ + end + + defp render_value_control(%{pending: %{control: :date_range, key: key}} = assigns) do + {from_name, to_name, from_val, to_val} = date_range_binding(assigns, key) + + assigns = + assigns + |> assign(:from_name, from_name) + |> assign(:to_name, to_name) + |> assign(:from_val, from_val) + |> assign(:to_val, to_val) + + ~H""" + <.control_shell title={@pending.label} wide={true}> +
+ +
+ + <%!-- Currently applied concrete range, locally formatted (dd.MM.yyyy). --%> +

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

+ + <%!-- Separate custom-range paragraph: two native + controls side by side (§3.12 native-first — no cally). Committing a bound + through the shared date dispatch applies the custom range; the browser's + own date popup gives year-jump, manual entry and WCAG a11y for free. --%> +
+ {gettext("Custom range")} + <.date_range_row + from_name={@from_name} + to_name={@to_name} + from_value={@from_val} + to_value={@to_val} + testid="custom-range" + /> +
+ + """ + end + + defp render_value_control(assigns), do: ~H"" + + # -------------------------------------------------------------------------- + # Events + # -------------------------------------------------------------------------- + + @impl true + def handle_event("toggle_picker", _params, socket) do + # Toggle so a second click on the trigger closes the open picker (matching the + # standard daisyUI dropdown behaviour). Opening it also clears any pending + # value control and resets the query. + if socket.assigns.open_picker do + {:noreply, assign(socket, :open_picker, false)} + else + {:noreply, + socket |> assign(:open_picker, true) |> assign(:pending, nil) |> assign(:picker_query, "")} + end + end + + @impl true + def handle_event("dismiss", _params, socket) do + # Drop-on-dismiss (§1.3): closing the picker or a pending value control + # without a selection adds no chip and triggers no re-load. + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:edit_anchor, nil) + |> assign(:pending, nil)} + end + + @impl true + def handle_event("filter_fields", %{"picker_query" => q}, socket) do + {:noreply, assign(socket, :picker_query, q)} + end + + @impl true + def handle_event("pick_field", %{"key" => key}, socket) do + descriptor = Enum.find(descriptors(socket.assigns), &(to_string(&1.key) == key)) + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:picker_query, "") + |> assign(:edit_anchor, nil) + |> assign(:pending, descriptor)} + end + + @impl true + def handle_event("edit_chip", %{"key" => key, "kind" => kind, "id" => id}, socket) do + # Editable chip (§1.18): re-open the field's value control pre-filled with the + # current state (the controls read straight from assigns, so no extra plumbing + # is needed to pre-fill them). Anchor the popover under the clicked chip by + # tracking its {kind, id}, so it opens in place rather than as a new pending + # chip at the row start. + descriptor = Enum.find(descriptors(socket.assigns), &(to_string(&1.key) == key)) + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:edit_anchor, {kind, id}) + |> assign(:pending, descriptor)} + end + + @impl true + def handle_event("apply_preset", %{"preset" => preset, "key" => key}, socket) do + case preset_range(preset) do + nil -> + {:noreply, socket} + + {from, to} -> + new_filters = put_date_range(socket.assigns.date_filters, key, from, to) + + maybe_send( + new_filters != socket.assigns.date_filters, + {:date_filters_changed, new_filters} + ) + + {:noreply, assign(socket, :pending, nil)} + end + end + + @impl true + def handle_event("commit", params, socket) do + dispatch_payment(socket, params) + dispatch_quick(socket, params) + dispatch_as_of_date(socket, params) + dispatch_exit_range(socket, params) + + dispatch_prefix( + socket, + params, + @group_filter_prefix, + :group_filter_changed, + socket.assigns.groups + ) + + dispatch_prefix( + socket, + params, + @fee_type_filter_prefix, + :fee_type_filter_changed, + socket.assigns.fee_types + ) + + dispatch_boolean(socket, params) + dispatch_dates(socket, params) + + {:noreply, socket} + end + + @impl true + def handle_event("remove_chip", %{"kind" => kind, "id" => id}, socket) do + remove_chip(kind, id, socket) + {:noreply, socket} + end + + @impl true + def handle_event("clear_all", _params, socket) do + send(self(), {:clear_all_filters}) + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:edit_anchor, nil) + |> assign(:pending, nil)} + end + + # --- dispatch helpers ----------------------------------------------------- + + # The payment value control carries a hidden `__payment` marker so its status, + # count-range, suspended flag and period are dispatched independently (each + # sub-control commits its own slice; none depends on another being set). + defp dispatch_payment(socket, %{"__payment" => _} = params) do + dispatch_payment_filter(socket, params) + dispatch_suspended(socket, params) + dispatch_payment_period(socket, params) + end + + defp dispatch_payment(_socket, _params), do: :ok + + defp dispatch_payment_filter(socket, params) do + filter = + case Map.get(params, "pay") do + "fully_paid" -> + :fully_paid + + "has_unpaid" -> + {:unpaid_range, parse_count(Map.get(params, "pay_min"), 1), + parse_count(Map.get(params, "pay_max"), nil)} + + _ -> + nil + end + + maybe_send(filter != socket.assigns.payment_filter, {:payment_filter_changed, filter}) + end + + defp dispatch_suspended(socket, params) do + suspended = Map.get(params, "suspended") == "1" + maybe_send(suspended != socket.assigns.suspended, {:suspended_changed, suspended}) + end + + defp dispatch_payment_period(socket, params) do + period = %{ + from: parse_date(Map.get(params, "pay_from")), + to: parse_date(Map.get(params, "pay_to")) + } + + maybe_send(period != socket.assigns.payment_period, {:payment_period_changed, period}) + end + + defp dispatch_quick(socket, params) do + case Map.get(params, "quick") do + q when q in ["active", "former", "all"] -> + new_filters = + DateFilter.set_quick_state(socket.assigns.date_filters, String.to_existing_atom(q)) + + maybe_send( + new_filters != socket.assigns.date_filters, + {:date_filters_changed, new_filters} + ) + + _ -> + :ok + end + end + + defp dispatch_as_of_date(socket, params) do + if Map.has_key?(params, "as_of_date") do + date = parse_date(Map.get(params, "as_of_date")) + maybe_send(date != socket.assigns.as_of_date, {:as_of_date_changed, date}) + end + end + + # The exit-date custom range writes only the exit_date slice + # (mode :custom + bounds), preserving join_date and custom-date entries — so + # editing the exit range never wipes another active date filter. Committing an + # empty range simply parks exit_date in :custom with nil bounds (no chip, no + # DB predicate), matching the join-date control's empty-range behaviour. + defp dispatch_exit_range(socket, params) do + if Map.has_key?(params, "ed_from") or Map.has_key?(params, "ed_to") do + new_filters = + Map.put(socket.assigns.date_filters, :exit_date, %{ + mode: :custom, + from: parse_date(Map.get(params, "ed_from")), + to: parse_date(Map.get(params, "ed_to")) + }) + + maybe_send(new_filters != socket.assigns.date_filters, {:date_filters_changed, new_filters}) + end + end + + defp dispatch_prefix(socket, params, prefix, message, valid_records) do + if Enum.any?(params, fn {k, _} -> String.starts_with?(k, prefix) end) do + dispatch_prefix_entries(socket, params, prefix, message, valid_records) + end + end + + defp dispatch_prefix_entries(socket, params, prefix, message, valid_records) do + current = current_for(socket, message) + valid_ids = MapSet.new(Enum.map(valid_records, &to_string(&1.id))) + + params + |> FilterParams.parse_prefix_filters(prefix, &FilterParams.parse_in_not_in_value/1) + |> Enum.each(&maybe_send_prefix(&1, current, valid_ids, message)) + end + + defp maybe_send_prefix({id_str, value}, current, valid_ids, message) do + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != value do + send(self(), {message, id_str, value}) + end + end + + defp current_for(socket, :group_filter_changed), do: socket.assigns.group_filters + defp current_for(socket, :fee_type_filter_changed), do: socket.assigns.fee_type_filters + + defp dispatch_boolean(socket, params) do + case Map.get(params, "custom_boolean") do + map when is_map(map) -> + Enum.each(map, &maybe_send_boolean(&1, socket.assigns.boolean_filters)) + + _ -> + :ok + end + end + + defp maybe_send_boolean({id_str, value_str}, current) do + value = parse_tri_state(value_str) + + if Map.get(current, id_str) != value do + send(self(), {:boolean_filter_changed, id_str, value}) + end + end + + defp dispatch_dates(socket, params) do + if has_date_param?(params) do + new_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields) + maybe_send(new_filters != socket.assigns.date_filters, {:date_filters_changed, new_filters}) + end + end + + # The exit-date range is dispatched separately (dispatch_exit_range/2) so it + # only touches the exit_date slice; from_params/2 here rebuilds the whole date + # map, so ed_* params are intentionally excluded from this trigger. + defp has_date_param?(params) do + Enum.any?(params, fn {k, _} -> + k in ["jd_from", "jd_to"] or + String.starts_with?(k, @custom_date_filter_prefix) + end) + end + + defp remove_chip("payment", _id, socket) do + maybe_send(socket.assigns.payment_filter != nil, {:payment_filter_changed, nil}) + end + + defp remove_chip("payment_period", _id, _socket) do + send(self(), {:payment_period_changed, PaymentAging.default_period()}) + end + + defp remove_chip("suspended", _id, _socket), do: send(self(), {:suspended_changed, false}) + defp remove_chip("as_of_date", _id, _socket), do: send(self(), {:as_of_date_changed, nil}) + + defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil}) + defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil}) + defp remove_chip("boolean", id, _socket), do: send(self(), {:boolean_filter_changed, id, nil}) + + defp remove_chip("date", id, socket) do + new_filters = clear_date_field(socket.assigns.date_filters, id) + send(self(), {:date_filters_changed, new_filters}) + end + + defp remove_chip(_kind, _id, _socket), do: :ok + + defp maybe_send(true, message), do: send(self(), message) + defp maybe_send(false, _message), do: :ok + + # Whether `chip` is the one currently being edited (§1.18): the edit anchor is + # the clicked chip's {kind, id}, uniquely identifying it in the row. + defp editing_chip?({kind, id}, %{kind: kind, id: id}), do: true + defp editing_chip?(_anchor, _chip), do: false + + # -------------------------------------------------------------------------- + # Chips + # -------------------------------------------------------------------------- + + defp applied_chips(assigns) do + group_chips(assigns) ++ + fee_type_chips(assigns) ++ + boolean_chips(assigns) ++ + date_chips(assigns) ++ + as_of_date_chip(assigns) ++ + payment_chips(assigns) ++ + suspended_chip(assigns) + end + + defp group_chips(assigns) do + names = id_name_map(assigns.groups) + + for {id, value} <- assigns.group_filters do + label = + case value do + :in -> gettext("Group: %{name}", name: Map.get(names, id, id)) + :not_in -> gettext("Group: not %{name}", name: Map.get(names, id, id)) + end + + %{kind: "group", id: id, edit_key: "group", label: label} + end + end + + defp fee_type_chips(assigns) do + names = id_name_map(assigns.fee_types) + + for {id, value} <- assigns.fee_type_filters do + label = + case value do + :in -> gettext("Fee type: %{name}", name: Map.get(names, id, id)) + :not_in -> gettext("Fee type: not %{name}", name: Map.get(names, id, id)) + end + + %{kind: "fee_type", id: id, edit_key: "fee_type", label: label} + end + end + + defp boolean_chips(assigns) do + names = id_name_map(assigns.boolean_custom_fields) + + for {id, value} <- assigns.boolean_filters do + state = if value, do: gettext("Yes"), else: gettext("No") + %{kind: "boolean", id: id, edit_key: id, label: "#{Map.get(names, id, id)}: #{state}"} + end + end + + defp date_chips(assigns) do + filters = assigns.date_filters + + exit_chip(filters) ++ + builtin_range_chip(filters, :join_date, gettext("Joined")) ++ + custom_date_chips(assigns) + end + + defp exit_chip(filters) do + # :active_only is the default exit-date state, so it is not a user-applied + # filter and renders no chip; only the non-default states do. + case DateFilter.quick_state(filters) do + :active -> + [] + + :former -> + [%{kind: "date", id: "exit_date", edit_key: "active_former", label: gettext("Former")}] + + :custom -> + range_chip( + "date", + "exit_date", + "active_former", + gettext("Exited"), + Map.get(filters, :exit_date) + ) + + :all -> + [ + %{ + kind: "date", + id: "exit_date", + edit_key: "active_former", + label: gettext("All (incl. former)") + } + ] + end + end + + defp builtin_range_chip(filters, field, prefix) do + range_chip("date", to_string(field), to_string(field), prefix, Map.get(filters, field)) + end + + defp custom_date_chips(assigns) do + names = id_name_map(assigns.date_custom_fields) + + assigns.date_filters + |> Enum.flat_map(fn + {k, %{} = bounds} when is_binary(k) -> + range_chip("date", k, k, Map.get(names, k, k), bounds) + + _ -> + [] + end) + end + + defp range_chip(kind, id, edit_key, prefix, %{} = bounds) do + from = bounds[:from] + to = bounds[:to] + + if from || to do + [ + %{ + kind: kind, + id: id, + edit_key: edit_key, + label: "#{prefix}: #{local_range(date_iso(from), date_iso(to))}" + } + ] + else + [] + end + end + + defp range_chip(_kind, _id, _edit_key, _prefix, _), do: [] + + defp payment_chips(assigns) do + filter_chip = + case assigns.payment_filter do + :fully_paid -> + [%{kind: "payment", id: "payment", edit_key: "payment", label: gettext("All paid")}] + + {:has_unpaid, n} -> + [ + %{ + kind: "payment", + id: "payment", + edit_key: "payment", + label: gettext("≥ %{n} unpaid", n: n) + } + ] + + {:unpaid_range, min, max} -> + [ + %{ + kind: "payment", + id: "payment", + edit_key: "payment", + label: unpaid_range_label(min, max) + } + ] + + _ -> + [] + end + + period = assigns.payment_period || %{} + + period_chip = + if period[:from] || period[:to] do + label = + "#{gettext("Contribution period")}: #{local_range(date_iso(period[:from]), date_iso(period[:to]))}" + + [%{kind: "payment_period", id: "payment_period", edit_key: "payment", label: label}] + else + [] + end + + filter_chip ++ period_chip + end + + defp suspended_chip(%{suspended: true}), + do: [ + %{kind: "suspended", id: "suspended", edit_key: "payment", label: gettext("Suspended fees")} + ] + + defp suspended_chip(_), do: [] + + defp as_of_date_chip(%{as_of_date: %Date{} = date}) do + [ + %{ + kind: "as_of_date", + id: "as_of_date", + edit_key: "active_former", + label: gettext("Active on %{date}", date: DateFormatter.format_date(date)) + } + ] + end + + defp as_of_date_chip(_), do: [] + + defp unpaid_range_label(min, nil) when min <= 1, do: gettext("Open contributions") + defp unpaid_range_label(min, nil), do: gettext("≥ %{n} unpaid", n: min) + defp unpaid_range_label(min, max) when min == max, do: gettext("%{n} unpaid", n: min) + defp unpaid_range_label(min, max), do: gettext("%{min}–%{max} unpaid", min: min, max: max) + + # -------------------------------------------------------------------------- + # Helpers + # -------------------------------------------------------------------------- + + defp descriptors(assigns) do + FilterDescriptor.all(%{ + groups: assigns.groups, + fee_types: assigns.fee_types, + custom_fields: assigns.boolean_custom_fields ++ assigns.date_custom_fields + }) + |> filter_by_query(assigns[:picker_query]) + end + + defp filter_by_query(descriptors, q) when is_binary(q) and q != "" do + down = String.downcase(q) + Enum.filter(descriptors, &String.contains?(String.downcase(&1.label), down)) + end + + defp filter_by_query(descriptors, _q), do: descriptors + + defp id_name_map(records) do + Map.new(records, fn r -> {to_string(r.id), r.name} end) + end + + attr :name, :string, required: true + attr :current, :atom, default: nil + + # No "All" option (§1.20): a cleared filter is simply the absence of a chip. + # Multi-select is OR for `:in` and exclude-all for `:not_in` (§1.29). Uses the + # same check/x Ja/Nein join-toggle as the boolean custom-field control so the + # two-state operator reads consistently: "Ja" = member is in this value + # (`:in`), "Nein" = member is not (`:not_in`). + defp in_not_in_radios(assigns) do + ~H""" + <.toggle_option name={@name} value="in" icon="hero-check-circle" checked={@current == :in}> + {gettext("Yes")} + + <.toggle_option + name={@name} + value="not_in" + icon="hero-x-circle" + checked={@current == :not_in} + > + {gettext("No")} + + """ + end + + attr :name, :string, required: true + attr :value, :string, required: true + attr :checked, :boolean, default: false + attr :icon, :string, default: nil + slot :inner_block, required: true + + # Shared two-state join toggle used by the boolean and is/is-not controls + # (Task 5): a `join-item` button label wrapping a native radio, with a visible + # selected state (`btn-primary` when the radio is checked) and an optional + # leading icon. Consistent look across every two-state selector. + # + # The radio is `sr-only` (visually hidden) rather than `hidden`/`display:none` + # so it stays keyboard-focusable and in the tab order: Tab reaches the group, + # native same-`name` radio semantics move the selection with the arrow keys + # (WCAG 2.1.1). `has-[:focus-visible]:*` surfaces a visible ring on the styled + # label while its input has keyboard focus (WCAG 2.4.7); `z-10` lifts the + # focused item so the ring is not clipped by adjacent join-items. + defp toggle_option(assigns) do + ~H""" + + """ + end + + defp group_label(:quick), do: gettext("Quick") + defp group_label(:membership), do: gettext("Membership") + defp group_label(:custom_fields), do: gettext("Custom fields") + + defp quick(filters), do: DateFilter.quick_state(filters) + + defp has_unpaid?({:has_unpaid, _}), do: true + defp has_unpaid?({:unpaid_range, _, _}), do: true + defp has_unpaid?(_), do: false + + defp range_min({:unpaid_range, min, _}), do: Integer.to_string(min) + defp range_min({:has_unpaid, n}), do: Integer.to_string(n) + defp range_min(_), do: "1" + + defp range_max({:unpaid_range, _, max}) when is_integer(max), do: Integer.to_string(max) + defp range_max(_), do: "" + + defp parse_tri_state("true"), do: true + defp parse_tri_state("false"), do: false + defp parse_tri_state(_), do: nil + + defp date_iso(%Date{} = d), do: Date.to_iso8601(d) + defp date_iso(_), do: nil + + # ISO string for a bound of the exit_date custom range, pre-filling the + # custom-range inputs only when the exit filter is actually in :custom + # mode (the quick active/former/all states carry no user range). + defp exit_bound_iso(filters, bound) do + case Map.get(filters, :exit_date, %{}) do + %{mode: :custom} = ed -> date_iso(Map.get(ed, bound)) || "" + _ -> "" + end + end + + # Locally formats an ISO from/to pair as "dd.MM.yyyy–dd.MM.yyyy" (either side + # may be blank), reusing the app-wide DateFormatter (§2.8). + defp local_range(from_iso, to_iso) do + "#{fmt_iso(from_iso)}–#{fmt_iso(to_iso)}" + end + + defp fmt_iso(iso) when is_binary(iso) and iso != "" do + case Date.from_iso8601(iso) do + {:ok, date} -> DateFormatter.format_date(date) + _ -> "…" + end + end + + defp fmt_iso(_), do: "…" + + defp parse_count(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {n, ""} when n > 0 -> n + _ -> default + end + end + + defp parse_count(_value, default), do: default + + # Builds the {from_name, to_name, from_val, to_val} tuple for a date_range + # control keyed by a built-in field (:join_date) or a custom date field UUID. + defp date_range_binding(assigns, :join_date) do + {"jd_from", "jd_to", date_for(assigns.date_filters, :join_date, :from), + date_for(assigns.date_filters, :join_date, :to)} + end + + defp date_range_binding(assigns, key) do + id = to_string(key) + bounds = Map.get(assigns.date_filters, id, %{}) + + {"#{@custom_date_filter_prefix}#{id}_from", "#{@custom_date_filter_prefix}#{id}_to", + date_iso(bounds[:from]) || "", date_iso(bounds[:to]) || ""} + end + + defp date_for(filters, field, bound) do + case filters do + %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) + _ -> "" + end + end + + # --- date presets --------------------------------------------------------- + + defp date_presets do + Enum.map(DatePresets.all(), fn preset -> {Atom.to_string(preset), preset_label(preset)} end) + end + + defp preset_label(:last_7_days), do: gettext("Last 7 days") + defp preset_label(:last_30_days), do: gettext("Last 30 days") + defp preset_label(:last_3_months), do: gettext("Last 3 months") + defp preset_label(:last_12_months), do: gettext("Last 12 months") + defp preset_label(:this_month), do: gettext("This month") + defp preset_label(:this_quarter), do: gettext("This quarter") + defp preset_label(:this_year), do: gettext("This year") + + # Resolves a preset string (from `phx-value-preset`) to a concrete `{from, to}` + # tuple via the shared, clock-free `DatePresets` resolver, or nil for an + # unknown preset. The resolver defines "this year" as period-to-date + # ([Jan 1, today]) per §2.8, superseding the prior full-year range. + defp preset_range(preset) when is_binary(preset) do + case Enum.find(DatePresets.all(), &(Atom.to_string(&1) == preset)) do + nil -> + nil + + key -> + %{from: from, to: to} = DatePresets.resolve(key) + {from, to} + end + end + + defp preset_range(_), do: nil + + # Writes a from/to range for a built-in or custom date field into the date + # filter map, matching DateFilter's internal shape. + defp put_date_range(filters, "join_date", from, to) do + Map.put(filters, :join_date, %{from: from, to: to}) + end + + defp put_date_range(filters, "exit_date", from, to) do + Map.put(filters, :exit_date, %{mode: :custom, from: from, to: to}) + end + + defp put_date_range(filters, key, from, to) when is_binary(key) do + Map.put(filters, key, %{from: from, to: to}) + end + + defp clear_date_field(filters, "join_date"), + do: Map.put(filters, :join_date, %{from: nil, to: nil}) + + defp clear_date_field(filters, "exit_date") do + Map.put(filters, :exit_date, %{mode: :active_only, from: nil, to: nil}) + end + + defp clear_date_field(filters, key) when is_binary(key), do: Map.delete(filters, key) + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex index 58777da3..6b073620 100644 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex @@ -69,12 +69,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do <.dropdown_menu id="field-visibility-menu" icon="hero-adjustments-horizontal" - button_label={gettext("Show/Hide Columns")} + button_label={gettext("Columns")} items={@all_items} checkboxes={true} selected={@selected_fields} open={@open} show_select_buttons={true} + show_reset_button={true} phx_target={@myself} /> @@ -115,6 +116,13 @@ 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 = diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex deleted file mode 100644 index a0753387..00000000 --- a/lib/mv_web/live/components/member_filter_component.ex +++ /dev/null @@ -1,1016 +0,0 @@ -defmodule MvWeb.Components.MemberFilterComponent do - @moduledoc """ - Provides the MemberFilter Live-Component. - - A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields. - Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters. - - ## Design Decisions - - - Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles - (padding, display, hover, font sizes) that would interfere with form controls. - - Filter controls are form elements (fieldset with legend, radio inputs), not menu items. - Uses semantic `
` and `` for proper accessibility and form structure. - - Dropdown stays open when clicking filter segments to allow multiple filter changes. - - Uses `phx-change` on form for radio inputs instead of individual `phx-click` events. - - ## Props - - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` - - `:groups` - List of groups (for per-group filter rows) - - `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group). - Multiple active filters combine with AND (member must match all selected group conditions). - - `:fee_types` - List of membership fee types (for per-fee-type filter rows) - - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - - `:boolean_custom_fields` - List of boolean custom fields to display - - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - - `:date_custom_fields` - List of date-typed custom fields rendered in the - "Custom date fields" section (each with `:id`, `:name`, `:value_type`). - - `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`): - built-in `:join_date` / `:exit_date` bounds and mode, plus optional - UUID-keyed custom date field bound entries. - - `:id` - Component ID (required) - - `:member_count` - Number of filtered members to display in badge (optional, default: 0) - - ## Events - - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes - - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes - - Sends `{:date_filters_changed, new_filters}` to parent when any date - filter input changes (built-in date bounds, exit_date mode, or custom - date field bounds). - """ - use MvWeb, :live_component - - alias MvWeb.MemberLive.Index.DateFilter - alias MvWeb.MemberLive.Index.FilterParams - - @group_filter_prefix Mv.Constants.group_filter_prefix() - @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() - @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() - - @impl true - def mount(socket) do - {:ok, assign(socket, :open, false)} - end - - @impl true - def update(assigns, socket) do - socket = - socket - |> assign(:id, assigns.id) - |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) - |> assign_group_assigns(assigns) - |> assign_fee_type_assigns(assigns) - |> assign_boolean_assigns(assigns) - |> assign_date_assigns(assigns) - |> assign(:member_count, assigns[:member_count] || 0) - - {:ok, socket} - end - - defp assign_group_assigns(socket, assigns) do - socket - |> assign(:groups, assigns[:groups] || []) - |> assign(:group_filters, assigns[:group_filters] || %{}) - |> assign(:group_filter_prefix, @group_filter_prefix) - end - - defp assign_fee_type_assigns(socket, assigns) do - socket - |> assign(:fee_types, assigns[:fee_types] || []) - |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) - |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) - end - - defp assign_boolean_assigns(socket, assigns) do - socket - |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) - |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) - end - - defp assign_date_assigns(socket, assigns) do - socket - |> assign(:date_custom_fields, assigns[:date_custom_fields] || []) - |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) - |> assign(:custom_date_filter_prefix, @custom_date_filter_prefix) - end - - @impl true - def render(assigns) do - ~H""" -
- <.button - type="button" - variant="secondary" - class={[ - "gap-2", - (@cycle_status_filter || map_size(@group_filters) > 0 || - map_size(@fee_type_filters) > 0 || - active_boolean_filters_count(@boolean_filters) > 0 || - date_filters_active?(@date_filters)) && - "btn-active" - ]} - phx-click="toggle_dropdown" - phx-target={@myself} - aria-haspopup="true" - aria-expanded={to_string(@open)} - aria-label={gettext("Filter members")} - > - <.icon name="hero-funnel" class="h-5 w-5" /> - - <.badge - :if={active_boolean_filters_count(@boolean_filters) > 0} - variant="primary" - size="sm" - > - {active_boolean_filters_count(@boolean_filters)} - - <.badge - :if={ - (@cycle_status_filter || map_size(@group_filters) > 0 || - map_size(@fee_type_filters) > 0 || - date_filters_active?(@date_filters)) && - active_boolean_filters_count(@boolean_filters) == 0 - } - variant="primary" - size="sm" - > - {@member_count} - - <.icon name="hero-chevron-down" class="size-4" /> - - - - -
- """ - end - - @impl true - def handle_event("toggle_dropdown", _params, socket) do - {:noreply, assign(socket, :open, !socket.assigns.open)} - end - - @impl true - def handle_event("close_dropdown", _params, socket) do - {:noreply, assign(socket, :open, false)} - end - - @impl true - def handle_event("update_filters", params, socket) do - payment_filter = parse_payment_filter(params) - - group_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @group_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) - - fee_type_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @fee_type_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) - - custom_boolean_filters_parsed = parse_custom_boolean_filters(params) - new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields) - - dispatch_payment_filter_change(socket, payment_filter) - dispatch_group_filter_changes(socket, group_filters_parsed) - dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) - dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) - dispatch_date_filters_change(socket, new_date_filters) - - {:noreply, socket} - end - - @impl true - def handle_event("reset_filters", _params, socket) do - # Send single message to reset all filters at once (performance optimization) - # This avoids N×2 load_members() calls when resetting multiple filters - send( - self(), - {:reset_all_filters, - %{ - cycle_status_filter: nil, - boolean_filters: %{}, - group_filters: %{}, - fee_type_filters: %{} - }} - ) - - # Close dropdown after reset - {:noreply, assign(socket, :open, false)} - end - - # Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false - defp parse_tri_state("true"), do: true - defp parse_tri_state("false"), do: false - defp parse_tri_state("all"), do: nil - defp parse_tri_state(_), do: nil - - defp parse_payment_filter(params) do - case Map.get(params, "payment_filter") do - "paid" -> :paid - "unpaid" -> :unpaid - _ -> nil - end - end - - defp parse_custom_boolean_filters(params) do - params - |> Map.get("custom_boolean", %{}) - |> Enum.reduce(%{}, fn {id_str, value_str}, acc -> - Map.put(acc, id_str, parse_tri_state(value_str)) - end) - end - - defp dispatch_payment_filter_change(socket, payment_filter) do - if payment_filter != socket.assigns.cycle_status_filter do - send(self(), {:payment_filter_changed, payment_filter}) - end - end - - defp dispatch_group_filter_changes(socket, group_filters_parsed) do - current = socket.assigns.group_filters - valid_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) - - Enum.each(group_filters_parsed, fn {id_str, new_value} -> - if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do - send(self(), {:group_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) do - current = socket.assigns.fee_type_filters - valid_ids = MapSet.new(Enum.map(socket.assigns.fee_types, &to_string(&1.id))) - - Enum.each(fee_type_filters_parsed, fn {id_str, new_value} -> - if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do - send(self(), {:fee_type_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) do - current = socket.assigns.boolean_filters - - Enum.each(custom_boolean_filters_parsed, fn {id_str, new_value} -> - if Map.get(current, id_str) != new_value do - send(self(), {:boolean_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_date_filters_change(socket, new_date_filters) do - if new_date_filters != socket.assigns.date_filters do - send(self(), {:date_filters_changed, new_date_filters}) - end - end - - # Get display label for button - defp button_label( - cycle_status_filter, - groups, - group_filters, - fee_types, - fee_type_filters, - boolean_custom_fields, - boolean_filters, - date_filters - ) do - active_count = - count_active_filter_categories( - cycle_status_filter, - group_filters, - fee_type_filters, - boolean_filters, - date_filters - ) - - if active_count >= 2 do - ngettext("%{count} filter active", "%{count} filters active", active_count, - count: active_count - ) - else - cond do - cycle_status_filter -> - payment_filter_label(cycle_status_filter) - - map_size(group_filters) > 0 -> - group_filters_label(groups, group_filters) - - map_size(fee_type_filters) > 0 -> - fee_type_filters_label(fee_types, fee_type_filters) - - map_size(boolean_filters) > 0 -> - boolean_filter_label(boolean_custom_fields, boolean_filters) - - date_filters_active?(date_filters) -> - gettext("Dates") - - true -> - gettext("Apply filters") - end - end - end - - defp count_active_filter_categories( - cycle_status_filter, - group_filters, - fee_type_filters, - boolean_filters, - date_filters - ) do - [ - cycle_status_filter, - map_size(group_filters) > 0, - map_size(fee_type_filters) > 0, - map_size(boolean_filters) > 0, - date_filters_active?(date_filters) - ] - |> Enum.count(& &1) - end - - # Date filter is "active" when its state differs from the default — i.e. the - # user selected something other than active-only with no custom date bounds. - defp date_filters_active?(date_filters) when is_map(date_filters) do - date_filters != DateFilter.default() - end - - defp date_filters_active?(_), do: false - - defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, - do: gettext("All") - - defp group_filters_label(groups, group_filters) do - groups_by_id = Map.new(groups, fn g -> {to_string(g.id), g.name} end) - - names = - group_filters - |> Enum.map(fn {group_id_str, _} -> Map.get(groups_by_id, group_id_str) end) - |> Enum.reject(&is_nil/1) - - label = Enum.join(names, ", ") - truncate_label(label, 30) - end - - defp fee_type_filters_label(_fee_types, fee_type_filters) when map_size(fee_type_filters) == 0, - do: gettext("All") - - defp fee_type_filters_label(fee_types, fee_type_filters) do - fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) - - parts = - fee_type_filters - |> Enum.map(fn {fee_type_id_str, value} -> - fee_type_filter_part(Map.get(fee_types_by_id, fee_type_id_str), value) - end) - |> Enum.reject(&is_nil/1) - - label = Enum.join(parts, ", ") - truncate_label(label, 30) - end - - defp fee_type_filter_part(nil, _value), do: nil - - defp fee_type_filter_part(name, :not_in), do: gettext("without %{name}", name: name) - defp fee_type_filter_part(name, _), do: name - - # Get payment filter label - defp payment_filter_label(nil), do: gettext("All") - defp payment_filter_label(:paid), do: gettext("Paid") - defp payment_filter_label(:unpaid), do: gettext("Unpaid") - - # Get boolean filter label (comma-separated list of active filter names) - defp boolean_filter_label(_boolean_custom_fields, boolean_filters) - when map_size(boolean_filters) == 0 do - gettext("All") - end - - defp boolean_filter_label(boolean_custom_fields, boolean_filters) do - # Get names of active boolean filters - active_filter_names = - boolean_filters - |> Enum.map(fn {custom_field_id_str, _value} -> - Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end) - end) - |> Enum.filter(&(&1 != nil)) - |> Enum.map(& &1.name) - - # Join with comma and truncate if too long - label = Enum.join(active_filter_names, ", ") - truncate_label(label, 30) - end - - # Truncate label if longer than max_length - defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label - - defp truncate_label(label, max_length) do - String.slice(label, 0, max_length) <> "..." - end - - # Count active boolean filters - defp active_boolean_filters_count(boolean_filters) do - map_size(boolean_filters) - end - - # Get CSS classes for payment filter label based on current state - defp payment_filter_label_class(current_filter, expected_value) do - base_classes = "join-item btn btn-sm" - is_active = current_filter == expected_value - - cond do - # All button (nil expected) - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - # Paid button - expected_value == :paid -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - # Unpaid button - expected_value == :unpaid -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" - end - end - - # Shared CSS classes for in/not_in filter labels (groups and fee types) - defp in_not_in_filter_label_class(filters, id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(filters, to_string(id)) - is_active = current_value == expected_value - - case {expected_value, is_active} do - {_, false} -> "#{base_classes} btn" - {nil, true} -> "#{base_classes} btn-active" - {:in, true} -> "#{base_classes} btn-success btn-active" - {:not_in, true} -> "#{base_classes} btn-error btn-active" - end - end - - defp group_filter_label_class(group_filters, group_id, expected_value) do - in_not_in_filter_label_class(group_filters, group_id, expected_value) - end - - defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do - in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value) - end - - # Get CSS classes for boolean filter label based on current state - defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(boolean_filters, to_string(custom_field_id)) - is_active = current_value == expected_value - - cond do - # All button (nil expected) - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - # True button - expected_value == true -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - # False button - expected_value == false -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" - end - end - - # --- Date filter helpers ---------------------------------------------- - - defp exit_mode(%{exit_date: %{mode: mode}}), do: mode - defp exit_mode(_), do: :active_only - - defp exit_mode_label_class(date_filters, expected) do - base_classes = "join-item btn btn-sm" - - if exit_mode(date_filters) == expected do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - end - - defp date_value_for_input(date_filters, field, bound) do - case date_filters do - %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) - _ -> "" - end - end - - defp custom_date_value_for_input(date_filters, field_id, bound) do - key = to_string(field_id) - - case Map.get(date_filters, key) do - %{^bound => %Date{} = d} -> Date.to_iso8601(d) - _ -> "" - end - end -end diff --git a/lib/mv_web/live/components/search_bar_component.ex b/lib/mv_web/live/components/search_bar_component.ex index ac03a637..d31c563e 100644 --- a/lib/mv_web/live/components/search_bar_component.ex +++ b/lib/mv_web/live/components/search_bar_component.ex @@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do phx-target={@myself} phx-debounce="300" /> + """ @@ -61,4 +72,10 @@ 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 diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index c4850c41..a3470a75 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -6,41 +6,57 @@ 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, assign(socket, assigns)} + {:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)} 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"""
- <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom"> - <.button - type="button" - variant="ghost" - aria-label={aria_sort(@field, @sort_field, @sort_order)} - class="select-none" - phx-click="sort" - phx-value-field={@field} - data-testid={@field} - > - {@label} - <%= if @sort_field == @field do %> - <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> - <% else %> - <.icon - name="hero-chevron-up-down" - class="opacity-40" - /> - <% end %> - - + +
""" end @@ -48,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- - defp aria_sort(field, sort_field, dir) when field == sort_field do + defp aria_sort(field, sort_field, dir, hint) when field == sort_field do case dir do :asc -> gettext("ascending") :desc -> gettext("descending") - nil -> gettext("Click to sort") + nil -> hint end end - defp aria_sort(_, _, _), do: gettext("Click to sort") + defp aria_sort(_, _, _, hint), do: hint end diff --git a/lib/mv_web/live/components/view_settings_dropdown_component.ex b/lib/mv_web/live/components/view_settings_dropdown_component.ex new file mode 100644 index 00000000..b6cbec7a --- /dev/null +++ b/lib/mv_web/live/components/view_settings_dropdown_component.ex @@ -0,0 +1,129 @@ +defmodule MvWeb.Components.ViewSettingsDropdownComponent do + @moduledoc """ + LiveComponent for the member-overview view settings (§1.20). + + Renders an icon button with a tooltip that opens a dropdown of toggles: + + * Compact mode — table row density (compact ↔ comfortable) + * Compact Member field — composite last+first name cell, with a sub-toggle + "include email" + * Compact address field — composite street / postal+city cell + + Turning a composite field off surfaces the underlying separate columns in the + table. The component is display-only: it emits `{:view_setting_toggled, key}` + to the parent LiveView, which owns and persists the settings. + + ## Props + - `:density` — `:compact` or `:comfortable` + - `:compact_member` — boolean + - `:member_include_email` — boolean + - `:compact_address` — boolean + - `:id` — component id + """ + + use MvWeb, :live_component + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:open, fn -> false end)} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.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" + > +
  • + {gettext("View settings")} +
  • + + <.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. --%> +
  • + <.toggle_item + setting="member_include_email" + label={gettext("include email")} + checked={@member_include_email} + target={@myself} + /> +
  • + + <.toggle_item + setting="compact_address" + label={gettext("Compact address field")} + checked={@compact_address} + target={@myself} + /> + +
    + """ + 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""" + + """ + 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c991f7fe..62a82026 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -4,24 +4,40 @@ defmodule MvWeb.MemberLive.Index do ## Features - Full-text search across member profiles using PostgreSQL tsvector - - Sortable columns (name, email, address fields) - - Bulk selection for future batch operations - - Real-time updates via LiveView - - Bookmarkable URLs with query parameters + - DB-side filtering, sorting, and keyset pagination (infinite scroll) + - Sortable columns, including composite Name (last→first) and Address (city→postal→street) + - Column manager and per-browser view settings (density, composite Member/Address, include-email) + - Bulk selection spanning the whole filtered set (select-all / copy emails / export) + - Bookmarkable URLs: filter/sort/search/view state is URL-encoded ## URL Parameters - - `query` - Search query string for full-text search - - `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date) - - `sort_order` - Sort direction (:asc or :desc) + All list state is URL-encoded so the view is bookmarkable and restored on reload: + - `query` - full-text search string + - `sort_field` / `sort_order` - sort column and direction (`asc` / `desc`) + - `fields` - visible-column selection + - `highlight` - member id to render as selected (return from the show page) + - group / fee-type / boolean / date custom-field filter params (see + `MvWeb.MemberLive.Index.FilterParams` and `DateFilter` for the encoding) - ## Events - - `select_member` - Toggle individual member selection - - `select_all` - Toggle selection of all visible members - - `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected + ## Events (`handle_event`) + - `select_member` / `select_all` - toggle individual / whole-filtered-set selection + - `select_row_and_navigate` - open a member's show page + - `load_more` - fetch and append the next keyset page (infinite scroll) + - `sort` - change the active sort column/direction + - `copy_emails` - copy emails of the selected members, or of the whole filtered set when none are selected + + ## Messages (`handle_info`, from the filter/search/view-settings components) + - `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`, + `fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter + change messages (`group_filter_changed`, `fee_type_filter_changed`, + `boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`, + `payment_period_changed`) ## Implementation Notes - - Search uses PostgreSQL full-text search (plainto_tsquery) - - Sort state is synced with URL for bookmarkability + - Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only + the loaded keyset window, not the whole table + - List state is synced with the URL for bookmarkability; a signature guard + avoids redundant reloads - Components communicate via `handle_info` for decoupling """ use MvWeb, :live_view @@ -30,18 +46,22 @@ defmodule MvWeb.MemberLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership - alias Mv.Membership.CustomFieldSort - alias Mv.Membership.Member, as: MemberResource alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter + alias MvWeb.Helpers.MembershipFeeHelpers + alias MvWeb.MemberLive.Index.AsOfDate alias MvWeb.MemberLive.Index.CustomFieldValueLookup alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.ExportPayload alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery + alias MvWeb.MemberLive.Index.PaymentAging + alias MvWeb.MemberLive.Index.ViewSettings require Ash.Query require Logger @@ -124,16 +144,31 @@ defmodule MvWeb.MemberLive.Index do # Load user field selection from session session_selection = FieldSelection.get_from_session(session) - # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice) + # Resolve the per-browser view settings first: the columns the manager offers + # (composite vs. constituent Name/Address columns) follow these settings (§7b). + view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket)) + compact_member = view_settings.compact_member + member_include_email = view_settings.member_include_email + compact_address = view_settings.compact_address + + # The dropdown offers exactly the columns applicable to the current view + # settings, so composite and constituent columns are never both listed. all_available_fields = - all_custom_fields - |> FieldVisibility.get_all_available_fields() + FieldVisibility.get_offered_fields( + all_custom_fields, + compact_member, + member_include_email, + compact_address + ) initial_selection = FieldVisibility.merge_with_global_settings( session_selection, settings, - all_custom_fields + all_custom_fields, + compact_member: compact_member, + member_include_email: member_include_email, + compact_address: compact_address ) socket = @@ -142,7 +177,10 @@ defmodule MvWeb.MemberLive.Index do |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) - |> assign(:cycle_status_filter, nil) + |> assign(:payment_filter, nil) + |> assign(:payment_period, PaymentAging.default_period()) + |> assign(:suspended, false) + |> assign(:as_of_date, nil) |> assign(:group_filters, %{}) |> assign(:groups, groups) |> assign(:fee_type_filters, %{}) @@ -171,13 +209,30 @@ defmodule MvWeb.MemberLive.Index do :member_fields_visible_computed, FieldVisibility.get_visible_member_fields_computed(initial_selection) ) - |> assign(:show_current_cycle, false) - |> assign(:membership_fee_status_filter, nil) + |> assign(:page, nil) + |> assign(:after_cursor, nil) + |> assign(:more?, false) + |> assign(:total_count, 0) + |> assign(:loading?, false) + # The bulk-actions dropdown's open state lives here so the mailto recipient + # list can be fetched lazily on open (never on a mere selection change). + |> assign(:bulk_actions_open, false) + |> assign(:mailto_bcc, "") + |> assign(:recipient_count, 0) + |> assign(:mailto_disabled?, false) + |> assign_view_settings(view_settings) + |> stream_configure(:members, dom_id: &"row-#{&1.id}") + |> stream(:members, []) |> assign_export_payload() {:ok, socket} end + # Number of members fetched per keyset page (matches the :overview action's + # default_limit). Mount loads one page; further pages arrive via infinite + # scroll (phx-viewport-bottom) so the socket never holds the full table. + @page_limit 50 + # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @@ -208,12 +263,15 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_member(id) |> update_selection_assigns()} end @impl true def handle_event("select_all", _params, socket) do - all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new() + # Select-all spans the whole filtered set, not just the loaded page (§1.17): + # re-query every matching id. Toggling off when all are already selected. + all_ids = all_filtered_member_ids(socket) selected = if MapSet.equal?(socket.assigns.selected_members, all_ids) do @@ -225,43 +283,43 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_all_members() + |> update_selection_assigns()} + end + + # The bulk-actions dropdown routes its open/close here (un-targeted) so the + # mailto recipient list is fetched only when the menu actually opens, not on + # every selection or filter change. + @impl true + def handle_event("toggle_dropdown", _params, socket) do + open? = !socket.assigns.bulk_actions_open + + socket = assign(socket, :bulk_actions_open, open?) + socket = if open?, do: assign_mailto_recipients(socket), else: socket + + {:noreply, socket} + end + + @impl true + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :bulk_actions_open, false)} + end + + @impl true + def handle_event("load_more", _params, socket) do + {:noreply, + socket + |> load_more() |> update_selection_assigns()} end - @impl true - def handle_event("toggle_cycle_view", _params, socket) do - new_show_current = !socket.assigns.show_current_cycle - - socket = - socket - |> assign(:show_current_cycle, new_show_current) - |> load_members() - |> update_selection_assigns() - - query_params = - build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - new_path = ~p"/members?#{query_params}" - - {:noreply, push_patch(socket, to: new_path, replace: true)} - end - @impl true def handle_event("copy_emails", _params, socket) do - members = socket.assigns.members - selected_ids = socket.assigns.selected_members - any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id)) - - # Recipients follow the current scope: the selection when present, otherwise - # every member in the (filtered) list. Members without an email are excluded - # in both cases (unchanged missing-email handling). With no selection we no - # longer hard-stop with "No members selected" — we act on the scope; the - # empty-recipient feedback below is preserved. - formatted_emails = scope_member_emails(members, selected_ids, any_selected?) + # Recipients follow the current scope, re-queried from the DB so a no-selection + # (all/filtered) copy or a select-all copy spans the whole filtered set rather + # than only the loaded page (§1.17). Members without an email are excluded; + # the empty-recipient feedback below is preserved. + formatted_emails = scope_emails(socket) email_count = length(formatted_emails) if email_count == 0 do @@ -310,14 +368,15 @@ defmodule MvWeb.MemberLive.Index do |> assign(:sort_order, new_order) |> update_sort_components(old_field, new_field, new_order) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() # URL sync - push_patch happens synchronously in the event handler query_params = build_query_params( opts_for_query_params(socket, %{ - sort_field: export_sort_field(socket.assigns.sort_field), - sort_order: export_sort_order(socket.assigns.sort_order) + sort_field: ExportPayload.sort_field(socket.assigns.sort_field), + sort_order: ExportPayload.sort_order(socket.assigns.sort_order) }) ) |> maybe_add_field_selection( @@ -325,7 +384,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns[:fields_in_url?] || false ) - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + {:noreply, push_reload(socket, ~p"/members?#{query_params}")} end # ----------------------------------------------------------------- @@ -341,12 +400,18 @@ defmodule MvWeb.MemberLive.Index do - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ + @impl true + def handle_info({:view_setting_toggled, key}, socket) do + {:noreply, toggle_view_setting(socket, key)} + end + @impl true def handle_info({:search_changed, q}, socket) do socket = socket |> assign(:query, q) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -358,26 +423,105 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:payment_filter_changed, filter}, socket) do socket = socket - |> assign(:cycle_status_filter, filter) + |> assign(:payment_filter, filter) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = - build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) + build_query_params(opts_for_query_params(socket, %{payment_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} + end + + @impl true + def handle_info({:payment_period_changed, period}, socket) do + socket = + socket + |> assign(:payment_period, period) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{payment_period: period})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_reload(socket, new_path)} + end + + @impl true + def handle_info({:clear_all_filters}, socket) do + handle_info( + {:reset_all_filters, + %{ + payment_filter: nil, + payment_period: PaymentAging.default_period(), + suspended: false, + as_of_date: nil, + group_filters: %{}, + fee_type_filters: %{}, + boolean_filters: %{}, + date_filters: DateFilter.default() + }}, + socket + ) + end + + @impl true + def handle_info({:suspended_changed, suspended}, socket) do + socket = + socket + |> assign(:suspended, suspended) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{suspended: suspended})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_reload(socket, new_path)} + end + + @impl true + def handle_info({:as_of_date_changed, as_of_date}, socket) do + socket = + socket + |> assign(:as_of_date, as_of_date) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{as_of_date: as_of_date})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_reload(socket, new_path)} end @impl true @@ -393,6 +537,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:boolean_custom_field_filters, updated_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -403,7 +548,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -421,6 +566,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:group_filters, group_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -431,7 +577,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -449,6 +595,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:fee_type_filters, fee_type_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -459,7 +606,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -468,6 +615,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:date_filters, new_date_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -478,65 +626,22 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} - end - - # Backward compatibility: tuple form delegates to map form - def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do - handle_info( - {:reset_all_filters, - %{ - cycle_status_filter: cycle_status_filter, - boolean_filters: boolean_filters, - group_filters: %{}, - fee_type_filters: %{} - }}, - socket - ) - end - - def handle_info( - {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters}, - socket - ) do - handle_info( - {:reset_all_filters, - %{ - cycle_status_filter: cycle_status_filter, - boolean_filters: boolean_filters, - group_filters: group_filters, - fee_type_filters: %{} - }}, - socket - ) - end - - def handle_info( - {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, - fee_type_filters}, - socket - ) do - handle_info( - {:reset_all_filters, - %{ - cycle_status_filter: cycle_status_filter, - boolean_filters: boolean_filters, - group_filters: group_filters, - fee_type_filters: fee_type_filters - }}, - socket - ) + {:noreply, push_reload(socket, new_path)} end def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket - |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) + |> assign(:payment_filter, Map.get(opts, :payment_filter)) + |> assign(:payment_period, Map.get(opts, :payment_period, PaymentAging.default_period())) |> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) + |> assign(:suspended, Map.get(opts, :suspended, false)) + |> assign(:as_of_date, Map.get(opts, :as_of_date)) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -547,40 +652,16 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true def handle_info({:field_toggled, field_string, visible}, socket) do new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) - socket = update_session_field_selection(socket, new_selection) - - final_selection = - FieldVisibility.merge_with_global_settings( - new_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) - - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_field_visibility(new_selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() @@ -590,35 +671,29 @@ defmodule MvWeb.MemberLive.Index do end @impl true - def handle_info({:fields_selected, selection}, socket) do - socket = update_session_field_selection(socket, selection) + def handle_info({:fields_reset}, socket) do + # Reset restores the curated default column set (§1.6): recompute the default + # visibility from the global settings and re-apply it as the selection. + {cm, ie, ca} = view_flags(socket) - final_selection = + default_selection = FieldVisibility.merge_with_global_settings( - selection, + %{}, socket.assigns.settings, - socket.assigns.all_custom_fields + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca ) - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + handle_info({:fields_selected, default_selection}, socket) + end + @impl true + def handle_info({:fields_selected, selection}, socket) do socket = socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_field_visibility(selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() @@ -646,35 +721,21 @@ defmodule MvWeb.MemberLive.Index do url_selection = FieldSelection.parse_from_url(params) final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) - socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) - |> maybe_update_cycle_status_filter(params) + |> maybe_update_payment_filter(params) + |> maybe_update_payment_period(params) + |> maybe_update_suspended(params) + |> maybe_update_as_of_date(params) |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_date_filters(params) - |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_visibility_derivations(final_selection) |> assign(:selected_member_id, parse_highlight_param(params["highlight"])) next_sig = build_signature(socket) @@ -691,6 +752,9 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # The (re)load is complete: clear the busy flag set by `push_reload/2` (§1.10). + socket = assign(socket, :loading?, false) + # Update sort components after rendering socket = if socket.assigns[:sort_needs_update] do @@ -712,10 +776,12 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, - socket.assigns.cycle_status_filter, + socket.assigns.payment_filter, + socket.assigns.payment_period, + socket.assigns[:suspended], + socket.assigns[:as_of_date], socket.assigns[:group_filters], socket.assigns[:fee_type_filters], - socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, socket.assigns[:visible_custom_field_ids] || [], @@ -737,8 +803,15 @@ defmodule MvWeb.MemberLive.Index do custom_field: custom_field, render: fn member -> case get_custom_field_value(member, custom_field) do - nil -> "" - cfv -> Formatter.format_custom_field_value(cfv.value, custom_field) + nil -> + "" + + cfv -> + if custom_field.value_type == :boolean do + extract_boolean_value(cfv.value) + else + Formatter.format_custom_field_value(cfv.value, custom_field) + end end end } @@ -806,23 +879,83 @@ defmodule MvWeb.MemberLive.Index do |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" - push_patch(socket, to: new_path, replace: true) + push_reload(socket, new_path) end - defp update_session_field_selection(socket, selection) do - assign(socket, :user_field_selection, selection) + # The current composite view flags; the offered column set is scoped to these. + defp view_flags(socket) do + {socket.assigns.compact_member, socket.assigns.member_include_email, + socket.assigns.compact_address} + end + + # Merges a raw selection with the global settings scoped to the current view + # settings, then assigns it together with the visibility-derived assigns. + defp assign_field_visibility(socket, selection) do + {cm, ie, ca} = view_flags(socket) + + final = + FieldVisibility.merge_with_global_settings( + selection, + socket.assigns.settings, + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca + ) + + assign_visibility_derivations(socket, final) + end + + # Assigns user_field_selection and every visibility-derived assign the table and + # export payload read from an already merged (offered-scoped) selection. + defp assign_visibility_derivations(socket, final_selection) do + visible_member_fields = + final_selection + |> FieldVisibility.get_visible_member_fields() + |> Enum.uniq() + + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign( + :member_fields_visible_db, + FieldVisibility.get_visible_member_fields_db(final_selection) + ) + |> assign( + :member_fields_visible_computed, + FieldVisibility.get_visible_member_fields_computed(final_selection) + ) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) end defp build_query_params(opts) when is_map(opts) do base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) - base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) - base_params = add_show_current_cycle(base_params, opts.show_current_cycle) + base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period) + base_params = add_status_params(base_params, opts[:suspended], opts[:as_of_date]) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) add_date_filters(base_params, opts.date_filters) end + # Suspended-status flag and as-of date serialize additively into the flat + # URL contract (suspended=1 / as_of_date=), omitted when inactive (§2.1). + defp add_status_params(params, suspended, as_of_date) do + params + |> Map.merge(PaymentAging.suspended_to_params(suspended)) + |> Map.merge(AsOfDate.to_params(as_of_date)) + end + + # Period-scoped payment model (§3.3): the payment-count filter and the active + # period both serialize into the flat URL contract (pay_filter/pay_from/pay_to). + defp add_payment_params(params, payment_filter, payment_period) do + params + |> Map.merge(PaymentAging.filter_to_params(payment_filter)) + |> Map.merge(PaymentAging.to_params(payment_period || PaymentAging.default_period())) + end + defp add_date_filters(params, date_filters) do Map.merge(params, DateFilter.to_params(date_filters)) end @@ -832,9 +965,11 @@ defmodule MvWeb.MemberLive.Index do query: socket.assigns.query, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, - cycle_status_filter: socket.assigns.cycle_status_filter, + payment_filter: socket.assigns.payment_filter, + payment_period: socket.assigns.payment_period, + suspended: socket.assigns[:suspended] || false, + as_of_date: socket.assigns[:as_of_date], group_filters: socket.assigns[:group_filters] || %{}, - show_current_cycle: socket.assigns.show_current_cycle, boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, fee_type_filters: socket.assigns[:fee_type_filters] || %{}, date_filters: socket.assigns.date_filters @@ -850,8 +985,14 @@ defmodule MvWeb.MemberLive.Index do end defp compute_final_field_selection(true, url_selection, socket) do + {cm, ie, ca} = view_flags(socket) + only_url = - FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) + FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca + ) visible_members = FieldVisibility.get_visible_member_fields(only_url) visible_custom = FieldVisibility.get_visible_custom_fields(only_url) @@ -865,6 +1006,8 @@ defmodule MvWeb.MemberLive.Index do end defp compute_final_field_selection(false, url_selection, socket) do + {cm, ie, ca} = view_flags(socket) + merged = FieldSelection.merge_sources( url_selection, @@ -875,7 +1018,10 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.merge_with_global_settings( merged, socket.assigns.settings, - socket.assigns.all_custom_fields + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca ) end @@ -887,6 +1033,181 @@ defmodule MvWeb.MemberLive.Index do end end + # The connect-info Plug.Conn (present on the initial dead render), used to read + # per-browser cookies such as the persisted view settings. + defp connect_conn(socket) do + case socket.private[:connect_info] do + %Plug.Conn{} = conn -> conn + _ -> nil + end + end + + # The LiveView connect params (present only on the connected mount). The client + # echoes the persisted view-settings cookie here because the connect-info map + # of a live socket does not expose cookies. + defp connect_params(socket) do + if connected?(socket), do: get_connect_params(socket), else: nil + end + + # Assigns the full view-settings map plus the derived per-setting assigns the + # template renders from. + defp assign_view_settings(socket, settings) do + socket + |> assign(:view_settings, settings) + |> assign(:density, settings.density) + |> assign(:compact_member, settings.compact_member) + |> assign(:member_include_email, settings.member_include_email) + |> assign(:compact_address, settings.compact_address) + end + + # Updates a single view setting, re-derives the template assigns and persists + # the whole settings map per browser (client writes the cookie; the connected + # mount reads it back via connect params). + defp update_view_setting(socket, key, value) do + settings = Map.put(socket.assigns.view_settings, key, value) + + socket + |> assign_view_settings(settings) + |> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)}) + end + + # Flips the density setting. This only re-renders the table wrapper attribute + # (row spacing token), so the streamed rows do not need to be re-rendered. + defp toggle_view_setting(socket, :density) do + new = if socket.assigns.density == :compact, do: :comfortable, else: :compact + update_view_setting(socket, :density, new) + end + + # Flips the "include email" sub-toggle. Only meaningful while the composite + # Member cell is on: it moves the email between an in-cell line (on) and a + # separate email column (off), so besides re-rendering the rows it recomputes + # which columns the manager offers and their visibility. + defp toggle_view_setting(socket, :member_include_email) do + new_value = not socket.assigns.view_settings.member_include_email + + socket + |> update_view_setting(:member_include_email, new_value) + |> apply_include_email_toggle(new_value) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Flips the compact Member field. Besides re-rendering the rows, this changes + # which columns the manager offers (composite "Name" vs. first-name/last-name/email, + # §7b). The email visibility carries over between the two representations, + # analogously to the "include email" sub-toggle: composite + include-email ⇄ the + # email column being visible. + defp toggle_view_setting(socket, :compact_member) do + new_value = not socket.assigns.view_settings.compact_member + email_visible? = email_currently_visible?(socket) + + socket + |> update_view_setting(:compact_member, new_value) + |> sync_include_email(new_value, email_visible?) + |> apply_member_toggle(new_value, email_visible?) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Flips the compact Address field: offers the composite "Address" vs. the + # separate street/house-number/postal-code/city columns, making the newly relevant columns + # visible and recomputing the offered set + visibility for the new mode. + defp toggle_view_setting(socket, :compact_address) do + new_value = not socket.assigns.view_settings.compact_address + + fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city] + + socket + |> update_view_setting(:compact_address, new_value) + |> apply_composite_toggle(fields) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Whether the member email is currently surfaced: while the composite is on, + # either folded into the cell (include_email) or as a separate email column; + # while it is off, as its own column. Read before the flip, so `compact_member` + # still holds the previous mode. + defp email_currently_visible?(socket) do + if socket.assigns.compact_member do + socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible + else + :email in socket.assigns.member_fields_visible + end + end + + # Turning include_email OFF surfaces the email as a separate column; make it + # visible. Turning it ON folds the email into the cell, so the column is no + # longer offered (the recompute drops it). + defp apply_include_email_toggle(socket, false = _include_email) do + selection = Map.put(socket.assigns.user_field_selection, "email", true) + recompute_offered(socket, selection) + end + + defp apply_include_email_toggle(socket, true = _include_email), + do: recompute_offered(socket, socket.assigns.user_field_selection) + + # When switching to the composite, mirror the email state onto the include-email + # sub-toggle so the email keeps being surfaced; when switching away, the + # sub-toggle is not applicable. + defp sync_include_email(socket, true = _new_compact, email_visible?), + do: update_view_setting(socket, :member_include_email, email_visible?) + + defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket + + # Makes the now-relevant Member columns visible for the new mode. Turning the + # composite on surfaces "Name" (with the email either folded via include_email + # or kept as a separate column); turning it off surfaces first-name/last-name and + # carries the prior email visibility onto the email column. + defp apply_member_toggle(socket, true = _new_compact, email_visible?) do + selection = + socket.assigns.user_field_selection + |> Map.put("name", true) + |> Map.put("email", email_visible?) + + recompute_offered(socket, selection) + end + + defp apply_member_toggle(socket, false = _new_compact, email_visible?) do + selection = + socket.assigns.user_field_selection + |> Map.put("first_name", true) + |> Map.put("last_name", true) + |> Map.put("email", email_visible?) + + recompute_offered(socket, selection) + end + + # Sets the given columns visible, then recomputes the offered set + visibility. + defp apply_composite_toggle(socket, fields) do + selection = + Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc -> + Map.put(acc, Atom.to_string(field), true) + end) + + recompute_offered(socket, selection) + end + + # Recomputes the offered column list and the derived visibility for the current + # mode. `update_view_setting` has already updated the compact_* assigns, so + # `view_flags/1` reflects the new mode here. + defp recompute_offered(socket, selection) do + {cm, ie, ca} = view_flags(socket) + + socket + |> assign( + :all_available_fields, + FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca) + ) + |> assign_field_visibility(selection) + end + # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid. defp parse_highlight_param(nil), do: nil defp parse_highlight_param(""), do: nil @@ -950,17 +1271,6 @@ defmodule MvWeb.MemberLive.Index do end) end - defp add_cycle_status_filter(params, nil), do: params - defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid") - - defp add_cycle_status_filter(params, :unpaid), - do: Map.put(params, "cycle_status_filter", "unpaid") - - defp add_cycle_status_filter(params, _), do: params - - defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true") - defp add_show_current_cycle(params, _), do: params - defp add_boolean_filters(params, boolean_filters) do Enum.reduce(boolean_filters, params, &add_boolean_filter/2) end @@ -975,81 +1285,156 @@ defmodule MvWeb.MemberLive.Index do # Loading members # ------------------------------------------------------------- + # Loads the first keyset page of the overview and resets the stream. All + # filtering and sorting run in PostgreSQL via the `:overview` read action + # (see OverviewQuery); the socket holds only the loaded window, never the + # whole table. defp load_members(socket) do - search_query = socket.assigns.query + # `count: true` runs one COUNT for the active filter set so the live region + # can announce the exact total matching count (§1.9), not just the loaded + # page size. + page = read_overview_page(socket, limit: @page_limit, count: true) - query = - Mv.Membership.Member - |> Ash.Query.new() - |> Ash.Query.select(@overview_fields) + socket + |> assign(:members, index_by_id(page.results)) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:total_count, page.count || length(page.results)) + |> AshPhoenix.LiveView.assign_page_and_stream_result(page, + results_key: :members, + page_key: :page, + stream_opts: [reset: true] + ) + end - query = load_custom_field_values(query, compute_ids_to_load(socket)) + # Returns the members scroll container to the top after a sort/filter reset. + # A reset re-streams from page 1, so a user who was scrolled down would be left + # past the (now shorter) content with infinite scroll not re-arming; scrolling + # back to the top shows the new ordering from #1 and re-arms the loader. Only + # used on genuine resets, never on load_more (which must preserve position). + # The client handler (app.js) is a no-op if the container is not found. + defp scroll_list_to_top(socket), do: push_event(socket, "members:scroll-top", %{}) - query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) + # Marks the table region busy (aria-busy, §1.10) for the reload triggered by a + # filter/sort/search patch, then hands off to the patch. `handle_params` clears + # the flag once the page has been (re)loaded. + defp push_reload(socket, path) do + socket + |> assign(:loading?, true) + |> push_patch(to: path, replace: true) + end - # Load groups for each member (id, name, slug only) - query = - Ash.Query.load(query, groups: [:id, :name, :slug]) + # Fetches the next keyset page (if any) and appends it to the stream and the + # loaded window. Triggered by phx-viewport-bottom; a no-op once the last page + # has been reached so the bottom sentinel stops fetching. + defp load_more(%{assigns: %{more?: false}} = socket), do: socket - # Load membership_fee_type when the column is visible or when sorting by it - query = - if :membership_fee_type in socket.assigns.member_fields_visible or - socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do - Ash.Query.load(query, membership_fee_type: [:id, :name]) - else - query - end + defp load_more(socket) do + page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor) - query = apply_search_filter(query, search_query) + socket = + Enum.reduce(page.results, socket, fn member, acc -> + stream_insert(acc, :members, member) + end) - query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + socket + |> assign(:members, Map.merge(socket.assigns.members, index_by_id(page.results))) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:page, %{page | results: nil}) + end - query = - apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Loaded members are held as a `%{id => member}` map (not a list): appends on + # load_more stay O(page) via Map.merge instead of copying an ever-growing list + # with `++`, and a per-row selection re-render is an O(1) lookup by id. + defp index_by_id(members), do: Map.new(members, &{&1.id, &1}) - # Built-in date filters (join_date, exit_date) are pushed to the DB so - # excluded rows never reach the BEAM. The active_only default is part of - # this — fresh load returns only members without an exit_date or with an - # exit_date strictly in the future. - query = - DateFilter.apply_ash_filter(query, socket.assigns.date_filters) + # Re-inserts a single loaded member into the stream so its row re-renders with + # the current selection state (checkbox + selected highlight). Stream rows are + # not re-rendered on assign changes, so a selection toggle must re-stream the + # affected row. + defp restream_member(socket, id) do + case Map.get(socket.assigns[:members] || %{}, id) do + nil -> socket + member -> stream_insert(socket, :members, member) + end + end - # Use ALL custom fields for sorting (not just show_in_overview subset) - custom_fields_for_sort = socket.assigns.all_custom_fields + # Re-inserts every loaded member so all visible rows reflect a bulk selection + # change (select-all / deselect-all). + defp restream_all_members(socket) do + Enum.reduce(Map.values(socket.assigns[:members] || %{}), socket, fn member, acc -> + stream_insert(acc, :members, member) + end) + end - {query, sort_after_load} = - maybe_sort( - query, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) + # Reads one keyset page of the overview with display loads applied. Reads run + # as the real user actor so Ash policies are enforced. + defp read_overview_page(socket, page_opts) do + socket + |> overview_query() + |> Ash.read!(actor: current_actor(socket), page: page_opts) + end - # Errors in handle_params are handled by Phoenix LiveView - actor = current_actor(socket) - members = Ash.read!(query, actor: actor) + # The cursor of the last loaded row, used as the `after` bound for the next + # page; nil once there are no further pages. + defp next_cursor(%{more?: true, results: results}) when results != [], + do: List.last(results).__metadata__.keyset - # Custom field values are already filtered at the database level in load_custom_field_values/2 - # No need for in-memory filtering anymore + defp next_cursor(_page), do: nil - members = apply_in_memory_filters(members, socket) + # Builds the `:overview` query (filter/sort via OverviewQuery) and layers on + # the display-only loads (custom field values, cycles, groups, fee type). + defp overview_query(socket) do + socket + |> overview_query_opts() + |> OverviewQuery.build() + |> Ash.Query.select(@overview_fields) + |> load_custom_field_values(compute_ids_to_load(socket)) + |> MembershipFeeStatus.load_cycles_for_members() + |> load_unpaid_cycle_count(socket) + |> Ash.Query.load(groups: [:id, :name, :slug]) + |> maybe_load_fee_type(socket) + end - # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) - # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status - members = - if sort_after_load and - socket.assigns.sort_field != :membership_fee_status do - sort_members_in_memory( - members, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) - else - members - end + # Loads the period-scoped unpaid-cycle count for the overview payment column + # (§1.13, §3.3). The count is a DB aggregate over `membership_fee_cycles` + # (denormalized `cycle_end`), scoped to the active period. + defp load_unpaid_cycle_count(query, socket) do + %{from: from, to: to} = socket.assigns.payment_period || PaymentAging.default_period() + Ash.Query.load(query, unpaid_cycle_count: %{period_from: from, period_to: to}) + end - assign(socket, :members, members) + defp maybe_load_fee_type(query, socket) do + if :membership_fee_type in socket.assigns.member_fields_visible or + socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + else + query + end + end + + # Assembles the filter/sort options OverviewQuery understands from the current + # LiveView assigns. + defp overview_query_opts(socket) do + %{ + search: socket.assigns.query, + group_filters: socket.assigns[:group_filters], + groups: socket.assigns[:groups], + fee_type_filters: socket.assigns[:fee_type_filters], + fee_types: socket.assigns[:fee_types], + boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters, + boolean_custom_fields: socket.assigns.boolean_custom_fields, + date_filters: socket.assigns.date_filters, + date_custom_fields: socket.assigns[:date_custom_fields], + payment_filter: socket.assigns.payment_filter, + payment_period: socket.assigns.payment_period, + suspended: socket.assigns[:suspended] || false, + as_of_date: socket.assigns[:as_of_date], + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + custom_fields: socket.assigns.all_custom_fields + } end # Collects every custom field UUID whose values must be loaded for a given @@ -1083,24 +1468,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.uniq() end - # Post-DB filtering: cycle status, boolean custom fields, and custom date - # fields. Date custom fields are last so they see the already-narrowed list. - defp apply_in_memory_filters(members, socket) do - members - |> apply_cycle_status_filter( - socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle - ) - |> apply_boolean_custom_field_filters( - socket.assigns.boolean_custom_field_filters, - socket.assigns.all_custom_fields - ) - |> DateFilter.apply_in_memory( - socket.assigns.date_filters, - socket.assigns[:date_custom_fields] || [] - ) - end - defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do @@ -1117,166 +1484,10 @@ defmodule MvWeb.MemberLive.Index do # Helper Functions # ------------------------------------------------------------- - defp apply_search_filter(query, search_query) do - if search_query && String.trim(search_query) != "" do - query - |> MemberResource.fuzzy_search(%{query: search_query}) - else - query - end - end - - # Multiple group filters combine with AND: member must match all selected group conditions. - defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query - - defp apply_group_filters(query, group_filters, groups) do - valid_ids = - groups - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> - member? = MapSet.member?(valid_ids, group_id_str) - - if member? do - apply_one_group_filter(q, group_id_str, value) - else - q - end - end) - end - - defp apply_one_group_filter(query, _group_id_str, nil), do: query - - defp apply_one_group_filter(query, group_id_str, :in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, group_id_str, :not_in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, _, _), do: query - - # Fee type filters: :in selections combine with OR (member has any of the selected types); - # :not_in selections combine with AND (member must not have type A and not have type B). - defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, - do: query - - defp apply_fee_type_filters(query, fee_type_filters, fee_types) do - valid_ids = - fee_types - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - {in_id_strs, 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 = - in_id_strs - |> Enum.map(fn {id_str, _} -> id_str end) - |> Enum.map(&Ecto.UUID.cast/1) - |> Enum.filter(&match?({:ok, _}, &1)) - |> Enum.map(fn {:ok, uuid} -> uuid 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 {fee_type_id_str, _}, q -> - apply_one_fee_type_filter(q, fee_type_id_str, :not_in) - end) - end - - defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do - case Ecto.UUID.cast(fee_type_id_str) do - {:ok, fee_type_uuid} -> - Ash.Query.filter( - query, - expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id)) - ) - - _ -> - query - end - end - - defp apply_cycle_status_filter(members, nil, _show_current), do: members - - defp apply_cycle_status_filter(members, status, show_current) - when status in [:paid, :unpaid] do - MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) - end - defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc - # Function to sort the column if needed. - # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash. - # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory. - defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false} - defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} - - defp maybe_sort(query, field, order, _custom_fields) do - # :groups is in computed_member_fields() but can be sorted in-memory - # Only :membership_fee_status should be blocked from sorting - if field == :membership_fee_status or field == "membership_fee_status" do - {query, false} - else - apply_sort_to_query(query, field, order) - end - end - - defp apply_sort_to_query(query, field, order) do - cond do - # Groups sort -> after load (in memory) - field in [:groups, "groups"] -> - {query, true} - - # Membership fee type sort -> by related name at DB - field in [:membership_fee_type, "membership_fee_type"] -> - {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false} - - # Custom field sort -> after load - custom_field_sort?(field) -> - {query, true} - - # DB field sort (atom) - is_atom(field) -> - {Ash.Query.sort(query, [{field, order}]), false} - - # DB field sort (string) -> convert only if allowed - is_binary(field) -> - case safe_member_field_atom_only(field) do - nil -> {query, false} - atom -> {Ash.Query.sort(query, [{atom, order}]), false} - end - - true -> - {query, false} - end - end - defp valid_sort_field?(field) when is_atom(field) do # :groups is in computed_member_fields() but can be sorted # Only :membership_fee_status should be blocked @@ -1300,7 +1511,9 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type] + + field in valid_fields or custom_field_sort?(field) or + field in [:groups, :membership_fee_type, :name, :address, :payment] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do @@ -1308,6 +1521,9 @@ defmodule MvWeb.MemberLive.Index do cond do field == "groups" -> :groups field == "membership_fee_type" -> :membership_fee_type + field == "name" -> :name + field == "address" -> :address + field == "payment" -> :payment true -> safe_member_field_atom_only(field) end @@ -1329,21 +1545,6 @@ defmodule MvWeb.MemberLive.Index do String.starts_with?(field, @custom_field_prefix) end - defp custom_field_sort?(_), do: false - - defp extract_custom_field_id(field) when is_atom(field) do - field |> Atom.to_string() |> extract_custom_field_id() - end - - defp extract_custom_field_id(field) when is_binary(field) do - case String.split(field, @custom_field_prefix) do - ["", id_str] -> id_str - _ -> nil - end - end - - defp extract_custom_field_id(_), do: nil - defp extract_custom_field_ids(visible_custom_fields) do Enum.map(visible_custom_fields, fn field_string -> case String.split(field_string, @custom_field_prefix) do @@ -1354,91 +1555,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1 != nil)) end - defp sort_members_in_memory(members, field, order, custom_fields) do - if field in [:groups, "groups"] do - sort_members_by_groups(members, order) - else - custom_field_id_str = extract_custom_field_id(field) - - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) - end - end - end - - defp sort_members_by_groups(members, order) do - # Members with groups first, then by first group name alphabetically (min = first by sort order) - first_group_name = fn member -> - (member.groups || []) - |> Enum.map(& &1.name) - |> Enum.min(fn -> nil end) - end - - members - |> Enum.sort_by(fn member -> - name = first_group_name.(member) - # Nil (no groups) sorts last in asc, first in desc - {name == nil, name || ""} - end) - |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end) - end - - defp sort_members_by_custom_field(members, id_str, order, custom_fields) do - custom_field = find_custom_field_by_id(custom_fields, id_str) - - case custom_field do - nil -> members - cf -> sort_members_with_custom_field(members, cf, order) - end - end - - defp find_custom_field_by_id(custom_fields, id_str) do - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - end - - defp sort_members_with_custom_field(members, custom_field, order) do - {members_with_values, members_without_values} = - split_members_by_value_presence(members, custom_field) - - sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) - sorted_with_values ++ members_without_values - end - - defp split_members_by_value_presence(members, custom_field) do - Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end) - end - - defp has_non_empty_value?(member, custom_field) do - case get_custom_field_value(member, custom_field) do - nil -> - false - - cfv -> - not empty_value?(cfv.value, custom_field.value_type) - end - end - - defp sort_members_with_values(members_with_values, custom_field, order) do - sorted = - Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, custom_field) - CustomFieldSort.sort_key(cfv.value, custom_field.value_type) - end) - - if order == :desc, do: Enum.reverse(sorted), else: sorted - end - - defp empty_value?(%Ash.Union{value: value, type: type}, _expected_type), - do: empty_value?(value, type) - - defp empty_value?(nil, _type), do: true - - defp empty_value?(value, type) when type in [:string, :email] and is_binary(value), - do: String.trim(value) == "" - - defp empty_value?(_value, _type), do: false - defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -1456,10 +1572,17 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, ""), do: default defp determine_field(default, nil), do: default + # Computed/pseudo fields that are nonetheless sortable (they resolve to DB + # sort keys in OverviewQuery): groups aggregate, the composite Name/Address + # columns, and the period-scoped payment (unpaid-cycle-count) column. Other + # computed fields (e.g. membership_fee_status) are not sortable. + @sortable_computed_fields [:groups, :name, :address, :payment] + defp determine_field(default, sf) when is_binary(sf) do - # Handle "groups" specially - it's in computed_member_fields() but can be sorted - if sf == "groups" do - :groups + sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1) + + if sf in sortable_strings do + String.to_existing_atom(sf) else computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) @@ -1470,9 +1593,8 @@ defmodule MvWeb.MemberLive.Index do end defp determine_field(default, sf) when is_atom(sf) do - # Handle :groups specially - it's in computed_member_fields() but can be sorted - if sf == :groups do - :groups + if sf in @sortable_computed_fields do + sf else if sf in FieldVisibility.computed_member_fields(), do: default, @@ -1514,13 +1636,23 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_search(socket, _params), do: socket - defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do - filter = determine_cycle_status_filter(filter_str) - assign(socket, :cycle_status_filter, filter) - end + # Period-scoped payment model (§3.3): a payment-count filter plus the active + # period, both driven purely from the URL so `handle_params` stays the source + # of truth. Absent params fall back to no filter / the all-outstanding period. + defp maybe_update_payment_filter(socket, params), + do: assign(socket, :payment_filter, PaymentAging.parse_filter_params(params)) - defp maybe_update_cycle_status_filter(socket, _params), - do: assign(socket, :cycle_status_filter, nil) + defp maybe_update_payment_period(socket, params), + do: assign(socket, :payment_period, PaymentAging.parse_period(params)) + + # Suspended-status filter (§1.23) and point-in-time as-of date (§1.24/§2.6): + # both decoded straight from the URL so `handle_params` remains the single + # source of truth. Absent params fall back to no filter. + defp maybe_update_suspended(socket, params), + do: assign(socket, :suspended, PaymentAging.parse_suspended(params)) + + defp maybe_update_as_of_date(socket, params), + do: assign(socket, :as_of_date, AsOfDate.parse(params)) defp maybe_update_group_filters(socket, params) when is_map(params) do prefix = @group_filter_prefix @@ -1616,10 +1748,6 @@ defmodule MvWeb.MemberLive.Index do defp normalize_uuid_string(_), do: nil - defp determine_cycle_status_filter("paid"), do: :paid - defp determine_cycle_status_filter("unpaid"), do: :unpaid - defp determine_cycle_status_filter(_), do: nil - defp maybe_update_boolean_filters(socket, params) do boolean_custom_fields = socket.assigns.all_custom_fields @@ -1692,11 +1820,6 @@ defmodule MvWeb.MemberLive.Index do defp determine_boolean_filter("false"), do: false defp determine_boolean_filter(_), do: nil - defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}), - do: assign(socket, :show_current_cycle, true) - - defp maybe_update_show_current_cycle(socket, _params), do: socket - # URL params are the source of truth for filter state on every navigation. # When no date filter params are present, this falls through to the # active_only default — exactly the spec behavior for fresh load (§1.1). @@ -1713,13 +1836,6 @@ defmodule MvWeb.MemberLive.Index do CustomFieldValueLookup.find_by_field(member, custom_field) end - def get_boolean_custom_field_value(member, custom_field) do - case get_custom_field_value(member, custom_field) do - nil -> nil - cfv -> extract_boolean_value(cfv.value) - end - end - defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}), do: extract_boolean_value(value) @@ -1775,14 +1891,6 @@ defmodule MvWeb.MemberLive.Index do end end - def format_selected_member_emails(members, selected_members) do - members - |> Enum.filter(fn member -> - MapSet.member?(selected_members, member.id) && member.email && member.email != "" - end) - |> Enum.map(&format_member_email/1) - end - def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id}) def format_member_email(member) do @@ -1799,12 +1907,28 @@ defmodule MvWeb.MemberLive.Index do def format_date(date), do: DateFormatter.format_date(date) - defp update_selection_assigns(socket) do - members = socket.assigns[:members] || [] - selected_members = socket.assigns.selected_members + @doc """ + The payment column header, naming the active aging period (§1.17). The + all-outstanding default (both bounds nil) reads just "Payment"; a bounded + period appends the range so the scope is always legible. + """ + def payment_column_label(_period), do: gettext("Fees") - selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) - any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) + @doc """ + Whether a payment period is actively scoped (at least one bound set), used to + decide whether the compact period indicator badge renders (§1.28). The + all-outstanding default (both bounds nil) shows no badge. + """ + def payment_period_active?(%{from: nil, to: nil}), do: false + def payment_period_active?(%{from: _, to: _}), do: true + def payment_period_active?(_), do: false + + defp update_selection_assigns(socket) do + selected_members = socket.assigns.selected_members + # The selection may span members beyond the loaded page (after select-all), + # so its size is the MapSet size, not a count over the loaded window. + selected_count = MapSet.size(selected_members) + any_selected? = selected_count > 0 # Scope drives the trigger label: the selection when present, otherwise the # whole list (filtered, when a search term or any filter is active). @@ -1815,13 +1939,31 @@ defmodule MvWeb.MemberLive.Index do true -> :all end - # Copy/Mailto recipients: the members in scope that have a usable email. - # With a selection that is the selected subset (existing behaviour); without - # a selection it is every member in scope (deliberate behaviour change). In - # both cases members without an email are excluded, exactly as today's - # format_selected_member_emails does for the selection case. - recipient_emails = scope_member_emails(members, selected_members, any_selected?) - recipient_count = length(recipient_emails) + # No DB work here: the mailto recipient list is computed lazily when the + # bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or + # filter change never reads members for a link the user may never open. + socket + |> assign(:selected_count, selected_count) + |> assign(:scope, scope) + |> assign_export_payload() + end + + # Fetches the mailto BCC recipients for the current bulk scope. Called only when + # the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered + # only then), keeping selection toggles free of DB reads. + defp assign_mailto_recipients(socket) do + selected_count = MapSet.size(socket.assigns.selected_members) + + # Size of the whole bulk scope (§1.17): the selection, or the full filtered + # total — never just the loaded page. + scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count + cap = Mv.Constants.max_mailto_bulk_recipients() + mailto_disabled? = scope_size >= cap + + # Mailto recipients are bounded by the cap; only re-query the (small) scope + # when it is within the cap, otherwise the link is disabled anyway. + recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap) + recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails) # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +) mailto_bcc = @@ -1830,33 +1972,64 @@ defmodule MvWeb.MemberLive.Index do |> URI.encode_www_form() |> String.replace("+", "%20") - mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients() - socket - |> assign(:selected_count, selected_count) - |> assign(:scope, scope) |> assign(:recipient_count, recipient_count) |> assign(:mailto_disabled?, mailto_disabled?) |> assign(:mailto_bcc, mailto_bcc) - |> assign_export_payload() end - # Returns the formatted "Name " recipient list for the current scope: - # the selected members when any are selected, otherwise every member in the - # (filtered) list. Members without an email are excluded in both cases. - defp scope_member_emails(members, selected_members, true = _any_selected?), - do: format_selected_member_emails(members, selected_members) - - defp scope_member_emails(members, _selected_members, false = _any_selected?) do - members + # Formatted "Name " recipients for the current bulk scope, re-queried + # from the DB so the set spans the whole selection / filtered total rather than + # only the loaded page (§1.17). Members without an email are excluded. + defp scope_emails(socket, opts \\ []) do + socket + |> scope_members(opts) |> Enum.filter(fn member -> member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end + # MapSet of every member id matching the active filters (no pagination), used + # by select-all so the selection spans the full filtered set (§1.17). + defp all_filtered_member_ids(socket) do + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + |> Ash.Query.select([:id]) + |> Ash.read!(actor: current_actor(socket)) + |> MapSet.new(& &1.id) + end + + # Members matching the current bulk scope: the selected members when any are + # selected, otherwise every member matching the active filters. Re-queried as + # the real user actor so policies are enforced. + defp scope_members(socket, opts) do + selected = socket.assigns.selected_members + + query = + if MapSet.size(selected) > 0 do + ids = MapSet.to_list(selected) + + Mv.Membership.Member + |> Ash.Query.for_read(:overview) + |> Ash.Query.filter(expr(id in ^ids)) + else + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + end + |> Ash.Query.select([:id, :first_name, :last_name, :email]) + + query = if opts[:limit], do: Ash.Query.limit(query, opts[:limit]), else: query + + Ash.read!(query, actor: current_actor(socket)) + end + @doc """ Returns true when the member list is restricted by a non-empty search term or any active filter (cycle status, group, fee type, boolean custom field, or a - date filter differing from the default). Drives the "(gefiltert)" vs "(alle)" + date filter differing from the default). Drives the "filtered" vs "all" trigger label and reads only assigns — no DB access. """ def filters_active?(assigns) do @@ -1869,7 +2042,7 @@ defmodule MvWeb.MemberLive.Index do end defp selection_filters_active?(assigns) do - not is_nil(assigns[:cycle_status_filter]) or + not is_nil(assigns[:payment_filter]) or map_size(assigns[:group_filters] || %{}) > 0 or map_size(assigns[:fee_type_filters] || %{}) > 0 or map_size(assigns[:boolean_custom_field_filters] || %{}) > 0 @@ -1880,149 +2053,7 @@ defmodule MvWeb.MemberLive.Index do end defp assign_export_payload(socket) do - payload = build_export_payload(socket) + payload = ExportPayload.build(socket.assigns) assign(socket, :export_payload_json, Jason.encode!(payload)) end - - defp build_export_payload(socket) do - visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] - - member_fields_db = socket.assigns[:member_fields_visible_db] || [] - member_fields_computed = socket.assigns[:member_fields_visible_computed] || [] - - # 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_export_member_fields_list( - ordered_member_fields_db, - socket.assigns[:member_fields_visible] - ) - - # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) - ordered_custom_field_ids = - socket.assigns.all_custom_fields - |> Enum.map(&to_string(&1.id)) - |> Enum.filter(&(&1 in visible_custom_field_ids)) - - %{ - selected_ids: socket.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: - export_column_order( - ordered_member_fields_db, - ordered_computed_fields, - ordered_custom_field_ids, - :membership_fee_type in socket.assigns[:member_fields_visible], - :groups in socket.assigns[:member_fields_visible] - ), - query: socket.assigns[:query] || nil, - sort_field: export_sort_field(socket.assigns[:sort_field]), - sort_order: export_sort_order(socket.assigns[:sort_order]), - show_current_cycle: socket.assigns[:show_current_cycle] || false, - cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]), - boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{} - } - end - - defp expand_db_string_for_export(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 build_export_member_fields_list(ordered_db, member_fields_visible) do - 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 export_cycle_status_filter(nil), do: nil - defp export_cycle_status_filter(:paid), do: "paid" - defp export_cycle_status_filter(:unpaid), do: "unpaid" - defp export_cycle_status_filter(_), do: nil - - defp export_sort_field(nil), do: nil - defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f) - defp export_sort_field(f) when is_binary(f), do: f - - defp export_sort_order(nil), do: nil - defp export_sort_order(:asc), do: "asc" - defp export_sort_order(:desc), do: "desc" - defp export_sort_order(o) when is_binary(o), do: o - # Build a single ordered list that matches the table order: - # - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db) - # - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible - # - groups appended before custom fields when visible - # - custom fields appended in the same order as table (already ordered_custom_field_ids) - defp export_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_for_export(&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 = - computed_strings - |> Enum.reject(&(&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 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index eb7085dc..39182330 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,6 +5,7 @@ <.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} @@ -20,17 +21,38 @@ -
    + <%!-- Two-row overview toolbar (Option C): row 1 is search + column/view + controls; row 2 is the dedicated filter row (add-filter + applied chips). --%> +
    +
    + <.live_component + module={MvWeb.Components.SearchBarComponent} + id="search-bar" + query={@query} + placeholder={gettext("Search...")} + /> +
    + <.live_component + module={MvWeb.Components.FieldVisibilityDropdownComponent} + id="field-visibility-dropdown" + all_fields={@all_available_fields} + custom_fields={@all_custom_fields} + selected_fields={@user_field_selection} + /> + <.live_component + module={MvWeb.Components.ViewSettingsDropdownComponent} + id="view-settings-dropdown" + density={@density} + compact_member={@compact_member} + member_include_email={@member_include_email} + compact_address={@compact_address} + /> +
    +
    + <.live_component - module={MvWeb.Components.SearchBarComponent} - id="search-bar" - query={@query} - placeholder={gettext("Search...")} - /> - <.live_component - module={MvWeb.Components.MemberFilterComponent} + module={MvWeb.Components.AddFilterBuilderComponent} id="member-filter" - cycle_status_filter={@cycle_status_filter} groups={@groups} group_filters={@group_filters} fee_types={@fee_types} @@ -39,47 +61,26 @@ boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} - member_count={length(@members)} - /> - <.tooltip - content={ - gettext( - "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." - ) - } - position="top" - > - <.button - type="button" - variant="secondary" - class={["gap-2", @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" /> - - - - <.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} + payment_filter={@payment_filter} + payment_period={@payment_period} + suspended={@suspended} + as_of_date={@as_of_date} />
    + <%!-- 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). --%> +
    + {ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)} +
    + <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
    <.table id="members" - rows={@members} + rows={@streams.members} + row_item={fn {_dom_id, member} -> member end} wrapper_overflow_class="overflow-visible" sticky_header={true} sticky_first_col={true} - 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")} + viewport_bottom={@more? && "load_more"} + row_click={ + fn {_dom_id, member} -> + JS.push("select_row_and_navigate", value: %{id: member.id}) + end + } row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} 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} col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} label={ ~H""" - <.input + Enum.map(& &1.id) |> MapSet.new())} + phx-hook="IndeterminateCheckbox" + data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)} + checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" /> """ } > - <.input + + <: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(" ") %> +
    +
    + {full_name} +
    +
    + {member.email} +
    +
    + <:col :let={member} :if={:first_name in @member_fields_visible} + sort_field={:first_name} label={ ~H""" <.live_component @@ -150,6 +199,7 @@ <:col :let={member} :if={:last_name in @member_fields_visible} + sort_field={:last_name} label={ ~H""" <.live_component @@ -168,6 +218,7 @@ <:col :let={member} :if={:email in @member_fields_visible} + sort_field={:email} label={ ~H""" <.live_component @@ -183,9 +234,127 @@ > {member.email} + <: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")}> +
    +
    {line1}
    +
    {line2}
    +
    + + + <: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} + + + <: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} + + + <: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} + + + <: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} + + <:col :let={member} :if={:join_date in @member_fields_visible} + sort_field={:join_date} label={ ~H""" <.live_component @@ -204,6 +373,7 @@ <:col :let={member} :if={:exit_date in @member_fields_visible} + sort_field={:exit_date} label={ ~H""" <.live_component @@ -229,6 +399,7 @@ <:col :let={member} :if={:country in @member_fields_visible} + sort_field={:country} label={ ~H""" <.live_component @@ -244,81 +415,10 @@ > {member.country} - <: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 - :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 - :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 - :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 :let={member} :if={:membership_fee_start_date in @member_fields_visible} + sort_field={:membership_fee_start_date} label={ ~H""" <.live_component @@ -337,6 +437,7 @@ <:col :let={member} :if={:membership_fee_type in @member_fields_visible} + sort_field={:membership_fee_type} label={ ~H""" <.live_component @@ -357,22 +458,122 @@ <:col :let={member} :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} + sort_field={:payment} + label={ + ~H""" +
    + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_payment} + field={:payment} + label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} + sort_hint={gettext("Click to sort by open payment cycles")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + <.badge + :if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)} + variant="primary" + size="sm" + class="font-normal shrink-0" + title={PaymentAging.period_tooltip(@payment_period)} + aria-label={PaymentAging.period_tooltip(@payment_period)} + data-testid="payment-period-badge" + > + {PaymentAging.period_short_code(@payment_period) || gettext("filtered")} + +
    + """ + } > - <%= if badge = MembershipFeeStatus.format_cycle_status_badge( - MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) - ) do %> - <.badge variant={badge.variant}> - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - <.empty_cell sr_text={gettext("No cycle")} /> + <% count = member.unpaid_cycle_count || 0 %> + <% badge = PaymentAging.badge(count) %> + <% tip_id = "payment-tip-#{member.id}" %> + <% anchor = "--pay-#{member.id}" %> + <%!-- The badge is a real navigation link (semantic, keyboard- and + right-click-friendly) into the member's payment history. Its hover/focus + tooltip is a native Popover-API element in the top layer, placed via CSS + anchor positioning (see the `payment-tip` class + the HoverPopover hook), + so it escapes the members table's `overflow` clipping without any custom + coordinate math. No tooltip renders when the member is fully paid. --%> + <.link + navigate={~p"/members/#{member.id}?tab=membership_fees"} + id={"payment-badge-#{member.id}"} + class={["badge badge-soft badge-md", badge.color_class]} + style={count > 0 && "anchor-name: #{anchor}"} + phx-hook={count > 0 && "HoverPopover"} + data-popover-target={count > 0 && tip_id} + aria-details={count > 0 && tip_id} + aria-label={gettext("%{label} — open payment history", label: badge.label)} + data-testid="payment-badge" + > + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <%= if count > 0 do %> + <% %{unpaid: unpaid, suspended: suspended} = + PaymentAging.partition_open_cycles(member, @payment_period) %> + <% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %> + <% end %> <:col :let={member} :if={:groups in @member_fields_visible} + sort_field={:groups} label={ ~H""" <.live_component @@ -387,15 +588,17 @@ } > <.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}> - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> +
    + <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
    <:action :let={member}> @@ -405,6 +608,37 @@
    + <: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). --%> + + <%!-- 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. --%> +
    + <.spinner size="sm" aria-hidden="true" /> + {gettext("Loading more members …")} +
    +
    diff --git a/lib/mv_web/live/member_live/index/as_of_date.ex b/lib/mv_web/live/member_live/index/as_of_date.ex new file mode 100644 index 00000000..6ec9d9a1 --- /dev/null +++ b/lib/mv_web/live/member_live/index/as_of_date.ex @@ -0,0 +1,37 @@ +defmodule MvWeb.MemberLive.Index.AsOfDate do + @moduledoc """ + URL codec for the point-in-time "active on X" membership filter (§1.24/§2.6). + + The as-of date is a single ISO-8601 date X carried in the flat URL contract + under the `as_of_date` key. When set, the overview keeps members who were + active on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the + predicate itself lives in `OverviewQuery`. Absent or malformed values decode + to nil (no filter), and nil encodes to the empty map so a fresh URL stays + canonical. + """ + + @as_of_date_param Mv.Constants.as_of_date_param() + + @doc """ + Decodes the as-of date from a params map. Missing or malformed ISO-8601 + values yield nil. + """ + @spec parse(map()) :: Date.t() | nil + def parse(params) when is_map(params), do: parse_date(Map.get(params, @as_of_date_param)) + + @doc """ + Encodes an as-of date into URL params. nil yields the empty map. + """ + @spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()} + def to_params(%Date{} = date), do: %{@as_of_date_param => Date.to_iso8601(date)} + def to_params(_), do: %{} + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/lib/mv_web/live/member_live/index/cookie.ex b/lib/mv_web/live/member_live/index/cookie.ex new file mode 100644 index 00000000..a3c2de88 --- /dev/null +++ b/lib/mv_web/live/member_live/index/cookie.ex @@ -0,0 +1,28 @@ +defmodule MvWeb.MemberLive.Index.Cookie do + @moduledoc """ + Shared cookie-header parsing for the member-overview persistence chain. + + Both `MvWeb.MemberLive.Index.ViewSettings` and + `MvWeb.MemberLive.Index.FieldSelection` read their persisted value from the + raw request `Cookie:` header on the disconnected mount, so the parsing lives + here once. + """ + + @doc """ + Parses a raw `Cookie:` request-header string into a `%{name => value}` map, + URL-decoding each value. A valueless entry (`"key"`) maps to `""`; malformed + segments are skipped. + """ + @spec parse_header(String.t()) :: %{String.t() => String.t()} + def parse_header(cookie_header) when is_binary(cookie_header) do + cookie_header + |> String.split(";") + |> Enum.map(&String.trim/1) + |> Enum.map(&String.split(&1, "=", parts: 2)) + |> Enum.reduce(%{}, fn + [key, value], acc -> Map.put(acc, key, URI.decode(value)) + [key], acc -> Map.put(acc, key, "") + _, acc -> acc + end) + end +end diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index 14d1aef0..ca6ed704 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -67,6 +67,52 @@ defmodule MvWeb.MemberLive.Index.DateFilter do } end + @typedoc """ + The three-state active/former quick filter. It is not a stored field but a + view onto `exit_date.mode`: `:active` ↔ `:active_only`, `:former` ↔ + `:inactive_only`, `:all` ↔ `:all`. A detailed `:custom` exit-date selection + has no quick equivalent and surfaces as `:custom` (no quick chip selected). + """ + @type quick_state :: :active | :former | :all | :custom + + @quick_to_mode %{active: :active_only, former: :inactive_only, all: :all} + + @doc """ + Derives the active/former quick-filter state from the single `exit_date` + source. Total function; an absent or malformed `exit_date` reads as the + default `:active`. This is the only reader — there is no second state to keep + in sync (§2.4). + """ + @spec quick_state(map()) :: quick_state() + def quick_state(filters) when is_map(filters) do + case Map.get(filters, :exit_date, %{}) do + %{mode: :active_only} -> :active + %{mode: :inactive_only} -> :former + %{mode: :all} -> :all + %{mode: :custom} -> :custom + _ -> :active + end + end + + @doc """ + Applies an active/former quick-filter choice onto the date filter state by + writing the corresponding `exit_date` mode and clearing its bounds. Only the + `exit_date` slice is touched; `join_date` and custom-date entries are left + intact, so the quick filter and the detailed control share one source. + """ + @spec set_quick_state(map(), :active | :former | :all) :: %{ + :exit_date => %{ + from: nil, + mode: :active_only | :inactive_only | :all, + to: nil + }, + optional(any()) => any() + } + def set_quick_state(filters, state) + when is_map(filters) and state in [:active, :former, :all] do + Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil}) + end + @doc """ Decodes URL params into a date filter state map. @@ -271,6 +317,54 @@ 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` diff --git a/lib/mv_web/live/member_live/index/date_presets.ex b/lib/mv_web/live/member_live/index/date_presets.ex new file mode 100644 index 00000000..dd886c94 --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_presets.ex @@ -0,0 +1,70 @@ +defmodule MvWeb.MemberLive.Index.DatePresets do + @moduledoc """ + Relative date-range presets for the date value controls (§1.25/§2.8). + + Each preset resolves against a reference date "today" into a concrete + `%{from: Date.t(), to: Date.t()}` range. Rolling "last N days/months" presets + end on today; the "this month/quarter/year" presets run period-to-date from + the respective period start up to today. Resolving is pure and clock-free — + the caller passes `today` (defaulting to `Date.utc_today/0`), so the resolved + bounds are testable and reproducible. + + The resolved bounds feed the same `jd_*` / `ed_*` / `cdf_*` URL params as an + absolute custom range; presets are purely an input convenience and add no new + persistence dimension. + """ + + @type preset :: + :last_7_days + | :last_30_days + | :last_3_months + | :last_12_months + | :this_month + | :this_quarter + | :this_year + + @type range :: %{from: Date.t(), to: Date.t()} + + @presets [ + :last_7_days, + :last_30_days, + :last_3_months, + :last_12_months, + :this_month, + :this_quarter, + :this_year + ] + + @doc """ + Returns every preset key in display order (rolling windows first, then the + period-to-date presets), for rendering the preset radio list. + """ + @spec all() :: [preset(), ...] + def all, do: @presets + + @doc """ + Whether `key` names a known preset. + """ + @spec preset?(term()) :: boolean() + def preset?(key), do: key in @presets + + @doc """ + Resolves a preset to a concrete `{from, to}` range relative to `today` + (default `Date.utc_today/0`). + """ + @spec resolve(preset(), Date.t()) :: range() + def resolve(preset, today \\ Date.utc_today()) + + def resolve(:last_7_days, today), do: %{from: Date.add(today, -6), to: today} + def resolve(:last_30_days, today), do: %{from: Date.add(today, -29), to: today} + def resolve(:last_3_months, today), do: %{from: Date.shift(today, month: -3), to: today} + def resolve(:last_12_months, today), do: %{from: Date.shift(today, month: -12), to: today} + def resolve(:this_month, today), do: %{from: %{today | day: 1}, to: today} + + def resolve(:this_quarter, today) do + first_month = div(today.month - 1, 3) * 3 + 1 + %{from: Date.new!(today.year, first_month, 1), to: today} + end + + def resolve(:this_year, today), do: %{from: Date.new!(today.year, 1, 1), to: today} +end diff --git a/lib/mv_web/live/member_live/index/export_payload.ex b/lib/mv_web/live/member_live/index/export_payload.ex new file mode 100644 index 00000000..637da8a4 --- /dev/null +++ b/lib/mv_web/live/member_live/index/export_payload.ex @@ -0,0 +1,162 @@ +defmodule MvWeb.MemberLive.Index.ExportPayload do + @moduledoc """ + Builds the member-overview export descriptor from the LiveView assigns. + + The overview hands this payload (as JSON) to the export flow so a CSV export + reproduces exactly the columns, order, filters, and sort the user currently + sees. It is pure: it reads the overview assigns and returns a plain map; the + LiveView owns encoding and assigning it. + + Column order mirrors the rendered table: + + * DB member fields in `Mv.Constants.member_fields/0` order; + * `membership_fee_type` / `membership_fee_status` inserted right after + `membership_fee_start_date` when visible; + * `groups` appended before the custom fields when visible; + * custom fields in the table's custom-field order. + """ + + alias MvWeb.MemberLive.Index.FieldVisibility + + @doc """ + Builds the export descriptor map from the overview assigns. + """ + @spec build(map()) :: map() + def build(assigns) do + visible_custom_field_ids = assigns[:visible_custom_field_ids] || [] + + member_fields_db = assigns[:member_fields_visible_db] || [] + member_fields_computed = assigns[:member_fields_visible_computed] || [] + member_fields_visible = assigns[:member_fields_visible] || [] + + # Order DB member fields exactly like the table/constants. + ordered_member_fields_db = + Mv.Constants.member_fields() + |> Enum.filter(&(&1 in member_fields_db)) + + # Order computed fields in canonical order. + ordered_computed_fields = + FieldVisibility.computed_member_fields() + |> Enum.filter(&(&1 in member_fields_computed)) + + member_fields_with_groups = + build_member_fields_list(ordered_member_fields_db, member_fields_visible) + + # Order custom fields like the table (same as dynamic_cols / all_custom_fields order). + ordered_custom_field_ids = + assigns.all_custom_fields + |> Enum.map(&to_string(&1.id)) + |> Enum.filter(&(&1 in visible_custom_field_ids)) + + %{ + selected_ids: assigns.selected_members |> MapSet.to_list(), + member_fields: + Enum.map(member_fields_with_groups, fn + f when is_atom(f) -> Atom.to_string(f) + f when is_binary(f) -> f + end), + computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), + custom_field_ids: ordered_custom_field_ids, + column_order: + column_order( + ordered_member_fields_db, + ordered_computed_fields, + ordered_custom_field_ids, + :membership_fee_type in member_fields_visible, + :groups in member_fields_visible + ), + query: assigns[:query] || nil, + sort_field: sort_field(assigns[:sort_field]), + sort_order: sort_order(assigns[:sort_order]), + # Decoupled from the removed overview current/last cycle toggle (§3.3): the + # export's fee-status column always uses the current cycle as its basis and + # applies no cycle-status row filter (the overview now filters payment via + # the period-scoped aging model, which the export does not mirror). + show_current_cycle: true, + cycle_status_filter: nil, + boolean_filters: assigns[:boolean_custom_field_filters] || %{} + } + end + + @doc "Serializes a sort field (atom or string) to its export string, or nil." + @spec sort_field(atom() | String.t() | nil) :: String.t() | nil + def sort_field(nil), do: nil + def sort_field(f) when is_atom(f), do: Atom.to_string(f) + def sort_field(f) when is_binary(f), do: f + + @doc "Serializes a sort order (atom or string) to its export string, or nil." + @spec sort_order(atom() | String.t() | nil) :: String.t() | nil + def sort_order(nil), do: nil + def sort_order(:asc), do: "asc" + def sort_order(:desc), do: "desc" + def sort_order(o) when is_binary(o), do: o + + defp build_member_fields_list(ordered_db, member_fields_visible) do + member_fields_visible = member_fields_visible || [] + + with_extras = + Enum.flat_map(ordered_db, fn f -> + if f == :membership_fee_start_date and :membership_fee_type in member_fields_visible do + [f, :membership_fee_type] + else + [f] + end + end) + + # If fee type is visible but start_date was not in the list, append it. + with_extras = + if :membership_fee_type in member_fields_visible and + :membership_fee_type not in with_extras do + with_extras ++ [:membership_fee_type] + else + with_extras + end + + if :groups in member_fields_visible, do: with_extras ++ [:groups], else: with_extras + end + + defp expand_db_string(f, membership_fee_type_visible, computed_strings) do + if f == "membership_fee_start_date" do + extra = + if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++ + if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: [] + + [f] ++ extra + else + [f] + end + end + + defp column_order( + ordered_member_fields_db, + ordered_computed_fields, + ordered_custom_field_ids, + membership_fee_type_visible, + groups_visible + ) do + db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1) + computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1) + + # Place membership_fee_type and membership_fee_status after membership_fee_start_date when present. + db_with_extras = + Enum.flat_map( + db_strings, + &expand_db_string(&1, membership_fee_type_visible, computed_strings) + ) + + # If fee type is visible but start_date was not in the list, append it before computed/groups. + db_with_extras = + if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do + db_with_extras ++ ["membership_fee_type"] + else + db_with_extras + end + + # Any remaining computed fields not inserted above (future-proof). + remaining_computed = Enum.reject(computed_strings, &(&1 in db_with_extras)) + + result = db_with_extras ++ remaining_computed + result = if groups_visible, do: result ++ ["groups"], else: result + result ++ ordered_custom_field_ids + end +end diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex index 846cf1dc..fecbb2a8 100644 --- a/lib/mv_web/live/member_live/index/field_selection.ex +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -29,6 +29,8 @@ 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" @@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do %{} [cookie_header | _rest] -> - cookies = parse_cookie_header(cookie_header) + cookies = Cookie.parse_header(cookie_header) case Map.get(cookies, @cookie_name) do nil -> %{} @@ -90,19 +92,6 @@ 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. diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 52ebe86f..e7baba78 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -29,51 +29,183 @@ 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). - @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups] + # :name and :address are composite display-only columns (Name = name + email, + # Address = street/house number over postal code/city). + # Order mirrors the overview table / export column order (fee type before fee + # status), so the Columns dropdown and the table agree. + @pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address] + + # The composite display columns and the constituent columns they replace. The + # column manager offers exactly one variant per group, selected by the view + # settings: the composite when the matching "compact" setting is on, otherwise + # the constituents (see `offered_member_fields/2`). + @name_composite :name + @name_constituents [:first_name, :last_name, :email] + @address_composite :address + @address_constituents [:street, :house_number, :postal_code, :city] # Export/API may accept this as alias; must not appear in the UI options list. @export_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 """ - Gets all available fields for selection. + Builds field selection from URL, scoped to the columns offered for the given + view settings (`:compact_member` / `:member_include_email` / `:compact_address` + in `opts`): fields in `url_selection` are visible, all others false. - Returns a list of field identifiers: - - 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) + Use when `?fields=...` is in the URL so column visibility is not merged with + global settings. """ - @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 - - @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()]) :: %{ + @spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{ 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) + 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)) + end + def selection_from_url_only(_, _, _), do: %{} + + defp do_selection_from_url_only(url_selection, all_fields) do Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) visible = Map.get(url_selection, field_string, false) @@ -81,40 +213,38 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do end) end - def selection_from_url_only(_, _), do: %{} + defp view_opts(opts) do + { + Keyword.get(opts, :compact_member, true), + Keyword.get(opts, :member_include_email, false), + Keyword.get(opts, :compact_address, true) + } + end @doc """ - Merges user field selection with global settings. + Merges user field selection with global settings, scoped to the columns offered + for the given view settings (`:compact_member` / `:member_include_email` / + `:compact_address` in `opts`). User selection takes priority over global settings. If a field is not in the - user selection, the global setting is used. If a field is not in global settings, - it defaults to `true` (visible). - - ## 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 + 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()] - ) :: %{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) + @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 + + defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do + global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set) Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) @@ -129,35 +259,6 @@ 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. @@ -259,15 +360,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) do - member_visibility = get_member_field_visibility_from_settings(settings) + defp get_global_visibility_map(settings, custom_fields, default_set) do + member_visibility = get_member_field_visibility_from_settings(settings, default_set) 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) do + defp get_member_field_visibility_from_settings(settings, default_set) do visibility_config = VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) @@ -276,13 +377,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do domain_map = Enum.reduce(domain_fields, %{}, fn field, acc -> field_string = Atom.to_string(field) - default_visibility = if field == :exit_date, do: false, else: true + default_visibility = MapSet.member?(default_set, field) show_in_overview = Map.get(visibility_config, field, default_visibility) 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), true) + Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field)) end) end diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex new file mode 100644 index 00000000..9cbde461 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -0,0 +1,145 @@ +defmodule MvWeb.MemberLive.Index.FilterDescriptor do + @moduledoc """ + Data-driven catalog of the filter fields offered by the member-overview + add-filter builder. + + Each descriptor is a plain, serializable map describing one pickable field: + + %{ + key: atom() | String.t(), # stable identifier (custom fields use their UUID string) + group: :quick | :membership | :custom_fields, + label: String.t(), # human-readable, gettext-translated + control: atom() # which type-aware value control the builder renders + } + + The catalog is the persistence basis the saved-views work (#549) docks onto: + it is derived purely from the field context (groups, fee types, custom fields) + and carries no UI or query state. + + Grouping (confirmed taxonomy): + + * `:quick` — Payment, Active/former (common shortcuts) + * `:membership` — Group, Fee type, Join date, Exit date + * `:custom_fields` — one entry per filterable club-defined custom field + (boolean and date fields; other value types are not filtered on the + overview) + + A group with no descriptors is omitted entirely (no header) — see + `visible_groups/1`. + """ + + use Gettext, backend: MvWeb.Gettext + + @type group :: :quick | :membership | :custom_fields + + @type t :: %{ + key: atom() | String.t(), + group: group(), + label: String.t(), + control: atom() + } + + # Fixed display order of the groups in the picker. + @group_order [:quick, :membership, :custom_fields] + + # Custom-field value types the overview can filter on. + @filterable_custom_field_types [:boolean, :date] + + @doc """ + Returns the ordered list of all descriptors available for the given field + context. Recognised context keys (all optional, default empty): + + * `:groups` — list of `%{id: _, name: _}` group structs + * `:fee_types` — list of fee-type structs + * `:custom_fields` — list of custom-field structs (`:value_type`, `:name`, `:id`) + """ + @spec all(map()) :: [t()] + def all(context) when is_map(context) do + quick() ++ + membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++ + custom_fields(Map.get(context, :custom_fields, [])) + end + + @doc "The group an individual descriptor belongs to." + @spec group_for(t()) :: group() + def group_for(%{group: group}), do: group + + @doc "The fixed display order of the picker groups." + @spec group_order() :: [group(), ...] + def group_order, do: @group_order + + @doc """ + Returns `[{group, [descriptor]}]` in display order, including only groups that + have at least one descriptor. Groups with no available fields are omitted so + the picker renders no empty header (§1.10). + """ + @spec visible_groups([t()]) :: [{group(), [t()]}] + def visible_groups(descriptors) when is_list(descriptors) do + grouped = Enum.group_by(descriptors, & &1.group) + + @group_order + |> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end) + |> Enum.reject(fn {_group, ds} -> ds == [] end) + end + + # --- static groups -------------------------------------------------------- + + defp quick do + [ + %{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count}, + %{ + key: :active_former, + group: :quick, + label: gettext("Active / former"), + control: :active_former + } + ] + end + + defp membership(groups, fee_types) do + maybe_group(groups) ++ + maybe_fee_type(fee_types) ++ + [ + %{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range}, + %{ + key: :exit_date, + group: :membership, + label: gettext("Exit date"), + control: :active_former + } + ] + end + + defp maybe_group([]), do: [] + + defp maybe_group(_groups), + do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}] + + defp maybe_fee_type([]), do: [] + + defp maybe_fee_type(_fee_types), + do: [ + %{ + key: :fee_type, + group: :membership, + label: gettext("Fee type"), + control: :fee_type_membership + } + ] + + defp custom_fields(custom_fields) do + custom_fields + |> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types)) + |> Enum.map(fn cf -> + %{ + key: to_string(cf.id), + group: :custom_fields, + label: cf.name, + control: control_for_custom_field(cf.value_type) + } + end) + end + + defp control_for_custom_field(:boolean), do: :boolean + defp control_for_custom_field(:date), do: :date_range +end diff --git a/lib/mv_web/member_live/index/membership_fee_status.ex b/lib/mv_web/live/member_live/index/membership_fee_status.ex similarity index 81% rename from lib/mv_web/member_live/index/membership_fee_status.ex rename to lib/mv_web/live/member_live/index/membership_fee_status.ex index d586b285..54571c49 100644 --- a/lib/mv_web/member_live/index/membership_fee_status.ex +++ b/lib/mv_web/live/member_live/index/membership_fee_status.ex @@ -21,8 +21,6 @@ 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 @@ -33,8 +31,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(), boolean(), Date.t() | nil) :: Ash.Query.t() - def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do + @spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t() + def load_cycles_for_members(query) do # Load membership_fee_type and cycles query |> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]]) @@ -158,30 +156,6 @@ 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") diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex new file mode 100644 index 00000000..fa5559ca --- /dev/null +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -0,0 +1,474 @@ +defmodule MvWeb.MemberLive.Index.OverviewQuery do + @moduledoc """ + Builds the Ash query for the member overview from the LiveView's filter/sort + state, so every filter and sort resolves in PostgreSQL via the `:overview` + keyset-paginated read action. + + All previously in-memory passes (cycle status, boolean/date custom fields, + group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker + is always appended to the sort so keyset pages never skip or duplicate rows. + + `build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:` + options to `Ash.read/2`. + """ + + import Ash.Expr + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.DateFilter + + require Ash.Query + require Ash.Sort + + @type opts :: %{optional(atom()) => term()} + + @doc """ + Builds the `:overview` query from the given filter/sort options. + + Recognised keys (all optional): + + * `:search` — full-text search string + * `:group_filters` / `:groups` — `%{group_id => :in | :not_in}` and the valid groups + * `:fee_type_filters` / `:fee_types` — `%{fee_type_id => :in | :not_in}` and valid fee types + * `:date_filters` — built-in join/exit date filter map (see `DateFilter`) + * `:sort_field` / `:sort_order` — sort key and direction + * `:today` — reference date for cycle math (defaults to `Date.utc_today/0`) + """ + @spec build(opts()) :: Ash.Query.t() + def build(opts \\ %{}) do + Member + |> Ash.Query.for_read(:overview) + |> apply_search(opts[:search]) + |> apply_group_filters(opts[:group_filters], opts[:groups]) + |> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types]) + |> apply_boolean_custom_field_filters( + opts[:boolean_custom_field_filters], + opts[:boolean_custom_fields] + ) + |> apply_date_filters(opts[:date_filters]) + |> apply_as_of_date_filter(opts[:as_of_date]) + |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) + |> apply_payment_filter(opts[:payment_filter], payment_period(opts)) + |> apply_suspended_filter(opts[:suspended], payment_period(opts)) + |> apply_sort( + opts[:sort_field], + opts[:sort_order], + opts[:custom_fields] || [], + payment_period(opts) + ) + end + + # --------------------------------------------------------------------------- + # Search + # --------------------------------------------------------------------------- + + defp apply_search(query, search) when is_binary(search), + do: Member.apply_overview_search(query, search) + + defp apply_search(query, _), do: query + + # --------------------------------------------------------------------------- + # Group filters (:in OR across selected groups; :not_in AND-of-not-exists) + # + # Multiple "is" selections bundle into a single membership `exists` over the + # union of group ids, so a member matching *any* selected group is kept (OR, + # §1.29/§2.7). Each "is not" selection stays an independent not-exists, so a + # member is excluded when it belongs to *any* of them. + # --------------------------------------------------------------------------- + + defp apply_group_filters(query, group_filters, _groups) + when group_filters in [nil, %{}], + do: query + + defp apply_group_filters(query, group_filters, groups) do + valid_ids = valid_id_set(groups) + + {in_filters, not_in_filters} = + group_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + query + |> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + |> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end)) + end + + defp apply_group_in_filter(query, id_strs) do + case cast_uuids(id_strs) do + [] -> query + uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids))) + end + end + + defp apply_group_not_in_filters(query, id_strs) do + Enum.reduce(id_strs, query, fn id_str, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid))) + _ -> q + end + end) + end + + # --------------------------------------------------------------------------- + # Fee-type filters (:in OR; :not_in AND) + # --------------------------------------------------------------------------- + + defp apply_fee_type_filters(query, fee_type_filters, _fee_types) + when fee_type_filters in [nil, %{}], + do: query + + defp apply_fee_type_filters(query, fee_type_filters, fee_types) do + valid_ids = valid_id_set(fee_types) + + {in_filters, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + + query = + if in_uuids == [] do + query + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {id_str, _}, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> + Ash.Query.filter( + q, + expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id)) + ) + + _ -> + q + end + end) + end + + # --------------------------------------------------------------------------- + # Boolean custom-field filters (AND across selected fields) + # + # Mirrors the previous in-memory behaviour: a member matches a `{field => bool}` + # filter only if it has a stored value row for that field whose boolean value + # equals `bool`. A member with no value row is excluded. Uses a JSONB + # containment predicate (`value @> '{"value": }'`), 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 diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex new file mode 100644 index 00000000..4f48c326 --- /dev/null +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -0,0 +1,395 @@ +defmodule MvWeb.MemberLive.Index.PaymentAging do + @moduledoc """ + Period-scoped payment aging for the member overview. + + The overview presents payment as an accounts-receivable aging count: per + member, the number of that member's cycles with `status == :unpaid` whose + denormalized `cycle_end` falls inside an active period. The period is a view + dimension that scopes the column and the payment filter together; its default + is **all outstanding** (both bounds nil → all time). Suspended cycles are + excluded from the count but surfaced separately in the badge tooltip. + + This module owns: + + * the period value shape and its default, + * URL param encode/decode for the period, + * the badge descriptor derived from a count, + * the open-cycles list (unpaid + suspended, member-relative) that backs the + badge tooltip. + + The count itself is computed DB-side by the `unpaid_cycle_count` calculation + on `Mv.Membership.Member`; `open_cycles/2` works over already-loaded cycles. + """ + + use Gettext, backend: MvWeb.Gettext + + alias Mv.Constants + alias MvWeb.Helpers.DateFormatter + alias MvWeb.Helpers.MembershipFeeHelpers + + @type period :: %{from: Date.t() | nil, to: Date.t() | nil} + + @type filter :: + nil + | :fully_paid + | {:has_unpaid, 1..3} + | {:unpaid_range, pos_integer(), pos_integer() | nil} + + @payment_period_from_param Constants.payment_period_from_param() + @payment_period_to_param Constants.payment_period_to_param() + @payment_filter_param Constants.payment_filter_param() + @payment_count_min_param Constants.payment_count_min_param() + @payment_count_max_param Constants.payment_count_max_param() + @suspended_param Constants.suspended_param() + + @doc """ + The default period: all outstanding cycles, all time (both bounds nil). + """ + @spec default_period() :: period() + def default_period, do: %{from: nil, to: nil} + + @doc """ + Decodes URL params into a period. Absent or malformed ISO-8601 bounds fall + back to nil, so no params yields the all-outstanding default. + """ + @spec parse_period(map()) :: period() + def parse_period(params) when is_map(params) do + %{ + from: parse_date(Map.get(params, @payment_period_from_param)), + to: parse_date(Map.get(params, @payment_period_to_param)) + } + end + + @doc """ + Encodes a period into URL params. A nil bound is omitted; the default period + encodes to the empty map (a fresh URL is the canonical default). + """ + @spec to_params(period()) :: %{optional(String.t()) => String.t()} + def to_params(%{from: from, to: to}) do + %{} + |> maybe_put_date(@payment_period_from_param, from) + |> maybe_put_date(@payment_period_to_param, to) + end + + def to_params(_), do: %{} + + @doc """ + Decodes the payment-count filter param. + + * `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period) + * `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}` + (at least N unpaid cycles in the period) + * anything else → `nil` (no payment-count filter) + """ + @spec parse_filter(term()) :: nil | :fully_paid | {:has_unpaid, 1..3} + def parse_filter("fully_paid"), do: :fully_paid + def parse_filter("unpaid_1"), do: {:has_unpaid, 1} + def parse_filter("unpaid_2"), do: {:has_unpaid, 2} + def parse_filter("unpaid_3"), do: {:has_unpaid, 3} + def parse_filter(_), do: nil + + @doc """ + Encodes a payment-count filter into URL params. `nil` yields the empty map. + + The two-sided `{:unpaid_range, min, max}` form encodes as + `pay_filter=has_unpaid` plus `pay_min` (and `pay_max` when bounded above), + so a fresh "has open" default (`min=1`, `max=nil`) round-trips canonically. + """ + @spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()} + def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"} + + def filter_to_params({:has_unpaid, n}) when n in 1..3, + do: %{@payment_filter_param => "unpaid_#{n}"} + + def filter_to_params({:unpaid_range, min, max}) when is_integer(min) and min > 0 do + %{@payment_filter_param => "has_unpaid", @payment_count_min_param => Integer.to_string(min)} + |> maybe_put_max(max) + end + + def filter_to_params(_), do: %{} + + defp maybe_put_max(params, max) when is_integer(max) and max > 0, + do: Map.put(params, @payment_count_max_param, Integer.to_string(max)) + + defp maybe_put_max(params, _max), do: params + + @doc """ + Decodes the payment-count filter from a full params map. + + A `pay_filter=has_unpaid` value reads the `pay_min`/`pay_max` bounds into a + `{:unpaid_range, min, max}`; an absent/invalid `pay_min` defaults to 1 and an + absent/invalid `pay_max` to nil (unbounded). Other `pay_filter` values fall + back to the single-token decode (`fully_paid` / `unpaid_N`). + """ + @spec parse_filter_params(map()) :: filter() + def parse_filter_params(params) when is_map(params) do + case Map.get(params, @payment_filter_param) do + "has_unpaid" -> + {:unpaid_range, parse_count(Map.get(params, @payment_count_min_param), 1), + parse_count(Map.get(params, @payment_count_max_param), nil)} + + other -> + parse_filter(other) + end + end + + defp parse_count(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {n, ""} when n > 0 -> n + _ -> default + end + end + + defp parse_count(_value, default), do: default + + @doc """ + Decodes the suspended-status filter flag (§1.23) from a params map. The flag + is present (`suspended=1`) only when active; any other value reads as false. + """ + @spec parse_suspended(map()) :: boolean() + def parse_suspended(params) when is_map(params), do: Map.get(params, @suspended_param) == "1" + + @doc """ + Encodes the suspended-status filter flag into URL params. Only `true` emits a + param (`suspended=1`); false/nil yield the empty map so a fresh URL is canonical. + """ + @spec suspended_to_params(boolean() | nil) :: %{optional(String.t()) => String.t()} + def suspended_to_params(true), do: %{@suspended_param => "1"} + def suspended_to_params(_), do: %{} + + @doc """ + Badge descriptor for an unpaid-cycle count. A count of 0 reads "All paid" + (success/green); a positive count reads "N open" (error/red). The color class + is derived from the shared `MembershipFeeHelpers.status_variant/1` mapping, so + the overview badge reads consistently with the member show page. + """ + @spec badge(non_neg_integer()) :: %{ + color_class: String.t(), + icon: String.t(), + label: String.t(), + count: non_neg_integer() + } + def badge(0) do + %{ + color_class: status_badge_class(:paid), + icon: MembershipFeeHelpers.status_icon(:paid), + label: gettext("All paid"), + count: 0 + } + end + + def badge(count) when is_integer(count) and count > 0 do + %{ + color_class: status_badge_class(:unpaid), + icon: MembershipFeeHelpers.status_icon(:unpaid), + label: gettext("%{count} open", count: count), + count: count + } + end + + @doc """ + DaisyUI badge color class for a cycle status, derived from the single shared + `MembershipFeeHelpers.status_variant/1` mapping so the overview badges, the + tooltip badges and the member show page all stay in lock-step. daisyUI emits + all `badge-*` component colors, so the interpolated class is always bundled. + """ + @spec status_badge_class(:paid | :unpaid | :suspended) :: String.t() + def status_badge_class(status), + do: "badge-#{MembershipFeeHelpers.status_variant(status)}" + + @doc """ + Compact short code for a payment period (§1.28), or nil when the period is not + compactly codeable (the header then falls back to a plain "filtered" badge). + + * `{nil, nil}` (all-outstanding default) → nil (no badge) + * a full calendar year (Jan 1 – Dec 31) → `"2026"` + * a full calendar quarter → `"Q1 2026"` + * anything else (arbitrary or open-ended range) → nil + """ + @spec period_short_code(period()) :: String.t() | nil + def period_short_code(%{from: %Date{} = from, to: %Date{} = to}) do + cond do + full_year?(from, to) -> Integer.to_string(from.year) + full_quarter?(from, to) -> "Q#{quarter(from.month)} #{from.year}" + true -> nil + end + end + + def period_short_code(_), do: nil + + @doc """ + Human-readable tooltip naming the active payment period with locally formatted + (`dd.MM.yyyy`) bounds (§1.28). Explains that the shown figures refer to the + filtered contribution period, then names it: the all-outstanding default + names all outstanding cycles; open-ended periods render an open bound as an + ellipsis. + """ + @spec period_tooltip(period()) :: String.t() + def period_tooltip(%{from: nil, to: nil}), + do: + gettext( + "The values refer to the filtered contribution period. Fees across all outstanding cycles" + ) + + def period_tooltip(%{from: from, to: to}) do + gettext( + "The values refer to the filtered contribution period. Fees in period %{from}–%{to}", + from: period_bound(from), + to: period_bound(to) + ) + end + + def period_tooltip(_), + do: + gettext( + "The values refer to the filtered contribution period. Fees across all outstanding cycles" + ) + + defp period_bound(%Date{} = d), do: DateFormatter.format_date(d) + defp period_bound(_), do: "…" + + defp full_year?(from, to), + do: + from.month == 1 and from.day == 1 and to.year == from.year and to.month == 12 and + to.day == 31 + + defp full_quarter?(from, to) do + from.day == 1 and from.month in [1, 4, 7, 10] and to.year == from.year and + to == Date.end_of_month(Date.new!(from.year, from.month + 2, 1)) + end + + @doc """ + A member's open cycles for the badge tooltip: the unpaid and suspended cycles + whose `cycle_end` falls inside `period`, merged into a single list sorted + chronologically by `cycle_start`. Paid cycles and cycles outside the period + are dropped. Expects `membership_fee_cycles` to be loaded on the member. + """ + @spec open_cycles(map(), period()) :: [map()] + def open_cycles(member, %{from: from, to: to}) do + member + |> in_period_cycles(from, to) + |> Enum.filter(&(&1.status in [:unpaid, :suspended])) + |> Enum.sort_by(& &1.cycle_start, Date) + end + + @doc """ + Partitions a member's open cycles for the period into the unpaid cycles (which + drive the count and the badge) and the suspended cycles, which the tooltip + surfaces in their own labeled section rather than by color alone (§1.14). + Both lists stay in chronological order. + """ + @spec partition_open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]} + def partition_open_cycles(member, period) do + {unpaid, suspended} = + member + |> open_cycles(period) + |> Enum.split_with(&(&1.status == :unpaid)) + + %{unpaid: unpaid, suspended: suspended} + end + + @tooltip_slots 5 + + @doc """ + Partitions the tooltip's unpaid sub-list into a fixed five-slot budget: at + most five badges for the unpaid cycles. Five or fewer unpaid cycles show in + full with no overflow. More than five collapse to the first four chronological + cycles plus a single overflow count (total − 4), so the unpaid sub-list + renders four cycle badges and one grey overflow badge — five items. Suspended + cycles are listed separately (in their own labeled section) and are not capped + by this budget, so the tooltip's overall badge count can exceed five. + """ + @spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()} + def tooltip_cycles(cycles) when is_list(cycles) do + if length(cycles) > @tooltip_slots do + {Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)} + else + {cycles, 0} + end + end + + @doc """ + Short, human-readable period label for a cycle, derived from its `cycle_start` + and the fee-type interval: + + * yearly → `"2025"` + * quarterly → `"Q1 2026"` + * half-yearly → `"H1 2026"` + * monthly → `"March 2026"` + + Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not + loaded/known. + """ + @spec short_period(map()) :: String.t() + def short_period(%{cycle_start: %Date{} = start} = cycle) do + case cycle_interval(cycle) do + :yearly -> Integer.to_string(start.year) + :quarterly -> "Q#{quarter(start.month)} #{start.year}" + :half_yearly -> "H#{half(start.month)} #{start.year}" + :monthly -> "#{month_name(start.month)} #{start.year}" + _ -> cycle_date_range(cycle) + end + end + + def short_period(cycle), do: cycle_date_range(cycle) + + defp in_period_cycles(member, from, to) do + case Map.get(member, :membership_fee_cycles) do + cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to)) + _ -> [] + end + end + + defp in_period?(%Date{} = cycle_end, from, to) do + (is_nil(from) or Date.compare(cycle_end, from) != :lt) and + (is_nil(to) or Date.compare(cycle_end, to) != :gt) + end + + defp in_period?(_, _, _), do: false + + defp cycle_interval(%{membership_fee_type: %{interval: interval}}) + when interval in [:monthly, :quarterly, :half_yearly, :yearly], + do: interval + + defp cycle_interval(_), do: nil + + defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}), + do: "#{DateFormatter.format_date(s)}–#{DateFormatter.format_date(e)}" + + defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s) + defp cycle_date_range(_), do: "" + + defp quarter(month), do: div(month - 1, 3) + 1 + + defp half(month) when month <= 6, do: 1 + defp half(_month), do: 2 + + defp month_name(1), do: gettext("January") + defp month_name(2), do: gettext("February") + defp month_name(3), do: gettext("March") + defp month_name(4), do: gettext("April") + defp month_name(5), do: gettext("May") + defp month_name(6), do: gettext("June") + defp month_name(7), do: gettext("July") + defp month_name(8), do: gettext("August") + defp month_name(9), do: gettext("September") + defp month_name(10), do: gettext("October") + defp month_name(11), do: gettext("November") + defp month_name(12), do: gettext("December") + + defp maybe_put_date(params, _key, nil), do: params + + defp maybe_put_date(params, key, %Date{} = date), + do: Map.put(params, key, Date.to_iso8601(date)) + + defp parse_date(nil), do: nil + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/lib/mv_web/live/member_live/index/view_settings.ex b/lib/mv_web/live/member_live/index/view_settings.ex new file mode 100644 index 00000000..f3e2644c --- /dev/null +++ b/lib/mv_web/live/member_live/index/view_settings.ex @@ -0,0 +1,175 @@ +defmodule MvWeb.MemberLive.Index.ViewSettings do + @moduledoc """ + Resolves and persists the member-overview view settings per browser/device. + + A view-settings value is a map of four keys: + + * `:density` — `:compact` or `:comfortable` (the row-spacing / compact mode) + * `:compact_member` — composite "Member" cell (last+first name) vs separate + first-name/last-name columns + * `:member_include_email` — whether the email is surfaced (email line in the + composite cell, or a separate Email column when the member field is split) + * `:compact_address` — composite Address cell (street / postal+city) vs + separate street/postal-code/city columns + + Persistence is per browser (not per account), resolved in priority order from + the LiveView connect params, the session, the request cookie, and finally the + global defaults (`defaults/0`). The chosen value is written to a long-lived + cookie by a small client-side listener; on the next connected mount the client + echoes the cookie back through the socket connect params (the connect-info map + on a live socket does not expose cookies), and on the disconnected render the + cookie is read directly from the request conn. + """ + + alias MvWeb.MemberLive.Index.Cookie + + @cookie_name "member_view_settings" + @session_key "member_view_settings" + @connect_param "view_settings" + @cookie_max_age 365 * 24 * 60 * 60 + + @defaults %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + + @densities [:compact, :comfortable] + @bool_keys [:compact_member, :member_include_email, :compact_address] + + @type t :: %{ + density: :compact | :comfortable, + compact_member: boolean(), + member_include_email: boolean(), + compact_address: boolean() + } + + @doc "The global default view settings used when nothing is stored." + @spec defaults() :: %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + def defaults, do: @defaults + + @doc "The cookie name the settings are persisted under (used by the client listener)." + @spec cookie_name() :: String.t() + def cookie_name, do: @cookie_name + + @doc "The cookie max-age in seconds (365 days)." + @spec cookie_max_age() :: 31_536_000 + def cookie_max_age, do: @cookie_max_age + + @doc """ + Parses a raw map (string or atom keys, string/boolean values) into a validated + partial settings map with atom keys. Unknown keys and invalid values are + dropped, so a caller can safely `Map.merge/2` the result over another source. + """ + @spec parse(term()) :: %{optional(atom()) => term()} + def parse(raw) when is_map(raw) do + Enum.reduce(raw, %{}, fn {key, value}, acc -> + case parse_pair(to_string(key), value) do + {k, v} -> Map.put(acc, k, v) + :error -> acc + end + end) + end + + def parse(_), do: %{} + + defp parse_pair("density", value) do + case parse_density(value) do + nil -> :error + density -> {:density, density} + end + end + + defp parse_pair(key, value) + when key in ~w(compact_member member_include_email compact_address) do + case parse_bool(value) do + nil -> :error + bool -> {String.to_existing_atom(key), bool} + end + end + + defp parse_pair(_key, _value), do: :error + + defp parse_density(value) when value in @densities, do: value + defp parse_density("compact"), do: :compact + defp parse_density("comfortable"), do: :comfortable + defp parse_density(_), do: nil + + defp parse_bool(value) when is_boolean(value), do: value + defp parse_bool("true"), do: true + defp parse_bool("false"), do: false + defp parse_bool(_), do: nil + + @doc "Reads partial settings from the LiveView session map." + @spec get_from_session(map()) :: %{optional(atom()) => term()} + def get_from_session(session) when is_map(session), + do: parse_json(Map.get(session, @session_key)) + + def get_from_session(_), do: %{} + + @doc "Reads partial settings from the request cookie header of a connect-info conn." + @spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()} + def get_from_cookie(%Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "cookie") do + [cookie_header | _rest] -> + cookie_header + |> Cookie.parse_header() + |> Map.get(@cookie_name) + |> parse_json() + + _ -> + %{} + end + end + + def get_from_cookie(_), do: %{} + + @doc "Reads partial settings from the LiveView connect params (raw JSON string)." + @spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()} + def get_from_connect_params(params) when is_map(params), + do: parse_json(Map.get(params, @connect_param)) + + def get_from_connect_params(_), do: %{} + + @doc """ + Resolves the effective view settings for a mount, merging the sources by + priority (connect params > session > cookie), falling back per key to the + global defaults. + """ + @spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t() + def resolve(session, conn, connect_params \\ nil) do + @defaults + |> Map.merge(get_from_cookie(conn)) + |> Map.merge(get_from_session(session)) + |> Map.merge(get_from_connect_params(connect_params)) + end + + @doc "Serializes settings to a JSON string for the client-side cookie writer." + @spec to_json(map()) :: String.t() + def to_json(settings) when is_map(settings) do + settings + |> Map.take([:density | @bool_keys]) + |> Map.new(fn + {:density, density} -> {:density, to_string(density)} + {key, value} -> {key, value} + end) + |> Jason.encode!() + end + + defp parse_json(nil), do: %{} + + defp parse_json(json) when is_binary(json) do + case Jason.decode(json) do + {:ok, decoded} -> parse(decoded) + _ -> %{} + end + end + + defp parse_json(_), do: %{} +end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index f1748591..d3979f46 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do end @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params(%{"id" => id} = params, _, socket) do actor = current_actor(socket) # Load custom fields once using assign_new to avoid repeated queries @@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do {:noreply, socket |> Layouts.assign_page_title(content_title) - |> assign(:member, member)} + |> assign(:member, member) + |> maybe_assign_tab(params)} end + # Deep-link support: the overview payment badge drills into the fees history + # via `?tab=membership_fees` (§1.15). + defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}), + do: assign(socket, :active_tab, :membership_fees) + + defp maybe_assign_tab(socket, _params), do: socket + @impl true def handle_event("switch_tab", %{"tab" => "contact"}, socket) do {:noreply, assign(socket, :active_tab, :contact)} diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index fa7abff8..9fd620c3 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -32,6 +32,8 @@ 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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 38ad3e54..5706d57b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -20,13 +20,6 @@ msgstr " (Datenfeld: %{field})" msgid "%{count} failed" msgstr "%{count} fehlgeschlagen" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "%{count} Filter aktiv" -msgstr[1] "%{count} Filter aktiv" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield." @@ -39,16 +32,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z msgid "%{count} synced" msgstr "%{count} synchronisiert" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "%{field} von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "%{field} bis" - #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -106,11 +89,6 @@ msgstr "Aktionen" msgid "Active members" msgstr "Aktive Mitglieder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Active only" -msgstr "Nur aktive" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -126,7 +104,9 @@ 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" @@ -147,7 +127,7 @@ msgid "Administration" msgstr "Administration" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -192,8 +172,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -209,11 +188,6 @@ msgstr "App-URL (Link zur Kontaktansicht)" msgid "Applicant data" msgstr "Angaben des Antragstellers" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "Filter auswählen" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -488,11 +462,6 @@ msgstr "CSV-Datei auswählen" msgid "City" msgstr "Stadt" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "Filter zurücksetzen" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -503,11 +472,6 @@ msgstr "Klicken für Details" msgid "Click for group details" msgstr "Klicke für Gruppen-Details" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "Klicke für Mitglieds-Details" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -549,11 +513,6 @@ msgstr "Client-ID" msgid "Client Secret" msgstr "Client-Geheimnis" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Close" -msgstr "Schließen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -815,11 +774,6 @@ msgstr "Aktueller Zyklus" msgid "Current amount" msgstr "Aktueller Betrag" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "Aktueller Zahlungszyklus" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" @@ -830,11 +784,6 @@ msgstr "Benutzerdefiniert" msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "Benutzerdefinierte Datumsfelder" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Custom field" @@ -914,11 +863,6 @@ msgstr "Datenfelder" msgid "Date" msgstr "Datum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "Daten" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1295,22 +1239,12 @@ msgstr "Beispiele" msgid "Exit Date" msgstr "Austrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" msgstr "Austrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date from" -msgstr "Austrittsdatum von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date to" -msgstr "Austrittsdatum bis" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1469,6 +1403,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." #: 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" @@ -1484,11 +1419,6 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." 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." @@ -1505,11 +1435,6 @@ msgstr "Feld" msgid "Fields on the join form" msgstr "Felder im Beitrittsformular" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "Mitglieder filtern" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1529,7 +1454,7 @@ msgstr "Vorname" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln." -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "Von" @@ -1656,7 +1581,6 @@ msgid "Group saved successfully." msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1771,11 +1695,6 @@ msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden." msgid "Inactive members" msgstr "Inaktive Mitglieder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "Nur ehemalige" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1811,7 +1730,6 @@ msgstr "Falsche E-Mail oder Passwort" msgid "Individual Datafields" msgstr "Individuelle Datenfelder" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1904,21 +1822,11 @@ msgstr "Beitrittsformular" msgid "Join confirmation" msgstr "Beitrittsbestätigung" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "Beitrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date from" -msgstr "Beitrittsdatum von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date to" -msgstr "Beitrittsdatum bis" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2008,11 +1916,6 @@ msgstr "Nachname" msgid "Last name" msgstr "Nachname" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "Letzter Zahlungszyklus" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2107,6 +2010,7 @@ msgstr "Als ausgesetzt markieren" msgid "Mark as unpaid" msgstr "Als unbezahlt markieren" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2153,11 +2057,6 @@ msgstr "Mitgliedsfeld" msgid "Member field %{action} successfully" msgstr "Mitgliedsfeld wurde erfolgreich %{action}" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Member filter" -msgstr "Mitgliedsfilter" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2243,7 +2142,6 @@ msgstr "Mitgliedsbeitrag" msgid "Membership Fee Start Date" msgstr "Startdatum Mitgliedsbeitrag" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Status" @@ -2288,14 +2186,12 @@ msgstr "Beitragseinstellungen" msgid "Membership fee start" msgstr "Beitragsbeginn" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" msgstr "Mitgliedsbeitragsart gelöscht" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" msgstr "Mitgliedsbeitragsart nicht gefunden" @@ -2350,12 +2246,14 @@ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/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" @@ -2406,7 +2304,8 @@ msgstr "Neue*r Benutzer*in" msgid "New amount" msgstr "Neuer Betrag" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2428,11 +2327,6 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "No approved or rejected requests yet" 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" @@ -2692,11 +2586,10 @@ msgid "Options" msgstr "Optionen" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: 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" @@ -2728,16 +2621,6 @@ msgstr "Beitragsdaten" msgid "Payment Interval" msgstr "Zahlungsintervall" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Status" -msgstr "Bezahlstatus" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "Zahlungen" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2836,11 +2719,6 @@ msgstr "Vierteljährlich" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "Zeitraum" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Reactivate member" @@ -3260,11 +3138,6 @@ msgstr "Server nicht erreichbar. Host und Port prüfen." msgid "Set Password" msgstr "Passwort setzen" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden." - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Settings saved successfully." @@ -3293,11 +3166,6 @@ 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" @@ -3391,10 +3259,11 @@ 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" @@ -3599,7 +3468,7 @@ msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier f msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "Bis" @@ -3699,11 +3568,10 @@ msgid "Unlinking scheduled" msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: 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" @@ -3904,7 +3772,8 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" msgid "Yearly Interval - Joining Cycle Included" msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -3956,8 +3825,7 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte msgid "You do not have permission to %{action} members." msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.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." @@ -3973,8 +3841,7 @@ 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/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.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." @@ -4079,6 +3946,7 @@ msgid "email %{email} has already been taken" msgstr "E-Mail %{email} wurde bereits verwendet" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "gefiltert" @@ -4129,11 +3997,6 @@ msgstr "aktualisiert" msgid "updated" msgstr "aktualisiert" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "ohne %{name}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4145,3 +4008,452 @@ 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" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 4c9a10a0..309f5ae4 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -21,13 +21,6 @@ msgstr "" msgid "%{count} failed" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "" -msgstr[1] "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "%{count} member has a value assigned for this datafield." @@ -40,16 +33,6 @@ msgstr[1] "" msgid "%{count} synced" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "" - #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -107,11 +90,6 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Active only" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -127,7 +105,9 @@ 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 "" @@ -148,7 +128,7 @@ msgid "Administration" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -209,11 +189,6 @@ msgstr "" msgid "Applicant data" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -488,11 +463,6 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -503,11 +473,6 @@ msgstr "" msgid "Click for group details" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -549,11 +514,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Close" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -815,11 +775,6 @@ msgstr "" msgid "Current amount" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" @@ -830,11 +785,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Custom field" @@ -914,11 +864,6 @@ msgstr "" msgid "Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1295,22 +1240,12 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date to" -msgstr "" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1469,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1484,11 +1420,6 @@ msgstr "" msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." 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." @@ -1505,11 +1436,6 @@ msgstr "" msgid "Fields on the join form" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1529,7 +1455,7 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "" @@ -1656,7 +1582,6 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1771,11 +1696,6 @@ msgstr "" msgid "Inactive members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1811,7 +1731,6 @@ msgstr "" msgid "Individual Datafields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1904,21 +1823,11 @@ msgstr "" msgid "Join confirmation" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date to" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2008,11 +1917,6 @@ msgstr "" msgid "Last name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2107,6 +2011,7 @@ msgstr "" msgid "Mark as unpaid" msgstr "" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2153,11 +2058,6 @@ msgstr "" msgid "Member field %{action} successfully" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Member filter" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2243,7 +2143,6 @@ msgstr "" msgid "Membership Fee Start Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Membership Fee Status" @@ -2348,12 +2247,14 @@ 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 "" @@ -2404,7 +2305,8 @@ msgstr "" msgid "New amount" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2426,11 +2328,6 @@ msgstr "" msgid "No approved or rejected requests yet" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2690,11 +2587,10 @@ msgid "Options" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -2726,16 +2622,6 @@ msgstr "" msgid "Payment Interval" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payment Status" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2834,11 +2720,6 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Reactivate member" @@ -3258,11 +3139,6 @@ msgstr "" msgid "Set Password" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings saved successfully." @@ -3291,11 +3167,6 @@ 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" @@ -3389,10 +3260,11 @@ 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 "" @@ -3597,7 +3469,7 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "" @@ -3697,11 +3569,10 @@ msgid "Unlinking scheduled" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "" @@ -3901,7 +3772,8 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4074,6 +3946,7 @@ msgid "email %{email} has already been taken" msgstr "" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "" @@ -4124,11 +3997,6 @@ msgstr "" msgid "updated" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4140,3 +4008,377 @@ 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index cda87b5a..a8decc87 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -21,13 +21,6 @@ msgstr "" msgid "%{count} failed" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "%{count} filter active" -msgstr[1] "%{count} filters active" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield." @@ -40,16 +33,6 @@ msgstr[1] "" msgid "%{count} synced" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "" - #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -107,11 +90,6 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Active only" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Add Member" @@ -127,7 +105,9 @@ 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 "" @@ -148,7 +128,7 @@ msgid "Administration" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -193,8 +173,7 @@ 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/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -210,11 +189,6 @@ msgstr "" msgid "Applicant data" msgstr "Applicant data" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -489,11 +463,6 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -504,11 +473,6 @@ msgstr "Click for details" msgid "Click for group details" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -550,11 +514,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Close" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -816,11 +775,6 @@ msgstr "" msgid "Current amount" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "Current payment cycle" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Custom" @@ -831,11 +785,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom date fields" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "Custom field" @@ -915,11 +864,6 @@ msgstr "" msgid "Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Dates" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1296,22 +1240,12 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Exit date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date to" -msgstr "" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1470,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" @@ -1485,11 +1420,6 @@ msgstr "" msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." 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." @@ -1506,11 +1436,6 @@ msgstr "" msgid "Fields on the join form" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1530,7 +1455,7 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "" @@ -1657,7 +1582,6 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1772,11 +1696,6 @@ msgstr "" msgid "Inactive members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1812,7 +1731,6 @@ msgstr "" msgid "Individual Datafields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1905,21 +1823,11 @@ msgstr "" msgid "Join confirmation" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format, fuzzy msgid "Join date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date to" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2009,11 +1917,6 @@ msgstr "" msgid "Last name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "Last payment cycle" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2108,6 +2011,7 @@ msgstr "" msgid "Mark as unpaid" msgstr "" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2154,11 +2058,6 @@ msgstr "" msgid "Member field %{action} successfully" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member filter" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2244,7 +2143,6 @@ msgstr "" msgid "Membership Fee Start Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Status" @@ -2289,14 +2187,12 @@ msgstr "" msgid "Membership fee start" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type deleted" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type not found" msgstr "" @@ -2351,12 +2247,14 @@ 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 "" @@ -2407,7 +2305,8 @@ msgstr "" msgid "New amount" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2429,11 +2328,6 @@ msgstr "" msgid "No approved or rejected requests yet" msgstr "No approved or rejected requests yet" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2693,11 +2587,10 @@ msgid "Options" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -2729,16 +2622,6 @@ msgstr "" msgid "Payment Interval" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Status" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2837,11 +2720,6 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Range" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Reactivate member" @@ -3261,11 +3139,6 @@ msgstr "" msgid "Set Password" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Settings saved successfully." @@ -3294,11 +3167,6 @@ 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" @@ -3392,10 +3260,11 @@ 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 "" @@ -3600,7 +3469,7 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "" @@ -3700,11 +3569,10 @@ msgid "Unlinking scheduled" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "" @@ -3904,7 +3772,8 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -3956,8 +3825,7 @@ msgstr "" msgid "You do not have permission to %{action} members." msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this membership fee type" msgstr "" @@ -3973,8 +3841,7 @@ msgstr "" msgid "You do not have permission to delete this member" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" msgstr "" @@ -4079,6 +3946,7 @@ msgid "email %{email} has already been taken" msgstr "" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "" @@ -4129,11 +3997,6 @@ msgstr "" msgid "updated" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "without %{name}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4145,3 +4008,487 @@ 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 "" diff --git a/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs b/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs new file mode 100644 index 00000000..5cabc9a5 --- /dev/null +++ b/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs @@ -0,0 +1,32 @@ +defmodule Mv.Repo.Migrations.AddCycleEndToMembershipFeeCycles do + @moduledoc """ + Adds the denormalized `cycle_end` column to `membership_fee_cycles`. + + The column is added nullable, backfilled from `cycle_start + interval` for every + existing row (via `Mv.MembershipFees.CycleEndBackfill`), and only then made + `NOT NULL`. Going forward the column is populated at create time by the + `SetCycleEnd` change. + """ + + use Ecto.Migration + + def up do + alter table(:membership_fee_cycles) do + add :cycle_end, :date + end + + flush() + + {:ok, _count} = Mv.MembershipFees.CycleEndBackfill.run() + + alter table(:membership_fee_cycles) do + modify :cycle_end, :date, null: false + end + end + + def down do + alter table(:membership_fee_cycles) do + remove :cycle_end + end + end +end diff --git a/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs b/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs new file mode 100644 index 00000000..00ba06c5 --- /dev/null +++ b/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddCustomFieldValuesValueGinIndex do + @moduledoc """ + Adds a GIN index on `custom_field_values.value` (JSONB) to serve the boolean + custom-field containment predicates used by the member overview. + """ + + use Ecto.Migration + + def up do + create index(:custom_field_values, ["value"], + name: "custom_field_values_value_gin_index", + using: "gin" + ) + end + + def down do + drop_if_exists index(:custom_field_values, ["value"], + name: "custom_field_values_value_gin_index" + ) + end +end diff --git a/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs new file mode 100644 index 00000000..b3fb8a55 --- /dev/null +++ b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs @@ -0,0 +1,22 @@ +defmodule Mv.Repo.Migrations.AddMembershipFeeCyclesStatusEndIndex do + @moduledoc """ + Adds a composite index on membership_fee_cycles(member_id, status, cycle_end) + to serve the period-scoped unpaid-cycle count on the member overview. + + This file was autogenerated with `mix ash_postgres.generate_migrations`. + """ + + use Ecto.Migration + + def up do + create index(:membership_fee_cycles, [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + ) + end + + def down do + drop_if_exists index(:membership_fee_cycles, [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + ) + end +end diff --git a/priv/resource_snapshots/repo/custom_field_values/20260630170638.json b/priv/resource_snapshots/repo/custom_field_values/20260630170638.json new file mode 100644 index 00000000..654d4231 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_field_values/20260630170638.json @@ -0,0 +1,148 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "custom_field_values_member_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "custom_field_values_custom_field_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "custom_fields" + }, + "scale": null, + "size": null, + "source": "custom_field_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "value" + ], + "fields": [ + { + "type": "string", + "value": "value" + } + ], + "include": null, + "message": null, + "name": "custom_field_values_value_gin_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": "gin", + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6B19604F0AA26F503094E6AF5086FDAE0D054096B24099376EFF4BBCFF7E3CB5", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_field_values_unique_custom_field_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "custom_field_id" + } + ], + "name": "unique_custom_field_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_field_values" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json new file mode 100644 index 00000000..e2646f6e --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json @@ -0,0 +1,173 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_start", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_end", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": 2, + "size": null, + "source": "amount", + "type": "decimal" + }, + { + "allow_nil?": false, + "default": "\"unpaid\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "455B6D49C804301DF13F950318644BF28B491535A94F4ABF7805AA1A53287401", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "membership_fee_cycles_unique_cycle_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "cycle_start" + } + ], + "name": "unique_cycle_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "membership_fee_cycles" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json new file mode 100644 index 00000000..5d974b60 --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json @@ -0,0 +1,206 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_start", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_end", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": 2, + "size": null, + "source": "amount", + "type": "decimal" + }, + { + "allow_nil?": false, + "default": "\"unpaid\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "member_id", + "status", + "cycle_end" + ], + "fields": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "status" + }, + { + "type": "atom", + "value": "cycle_end" + } + ], + "include": null, + "message": null, + "name": "membership_fee_cycles_member_status_end_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6CDBA37AC6000D63F3014D3EED08B613B32E8D908D598248747EFA093BC5246A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "membership_fee_cycles_unique_cycle_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "cycle_start" + } + ], + "name": "unique_cycle_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "membership_fee_cycles" +} \ No newline at end of file diff --git a/test/membership_fees/cycle_end_test.exs b/test/membership_fees/cycle_end_test.exs new file mode 100644 index 00000000..23e6ed59 --- /dev/null +++ b/test/membership_fees/cycle_end_test.exs @@ -0,0 +1,98 @@ +defmodule Mv.MembershipFees.CycleEndTest do + @moduledoc """ + Tests for the denormalized `cycle_end` column on membership fee cycles: + the create-time change that sets it, and the one-time backfill helper. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2] + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.CycleEndBackfill + alias Mv.MembershipFees.MembershipFeeCycle + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp create_member(fee_type, actor) do + member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor) + end + + describe "create sets cycle_end" do + for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do + test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do + interval = unquote(interval) + fee_type = create_fee_type(%{interval: interval}, actor) + member = create_member(fee_type, actor) + cycle_start = ~D[2024-02-01] + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: cycle_start, + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + |> Ash.create!(actor: actor) + + assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval) + end + end + end + + describe "create with interval argument" do + test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do + # Fee type carries :yearly, but the caller passes :monthly explicitly. + # If the interval argument is honored (no per-row fee-type lookup), the + # stored cycle_end reflects the argument, not the fee type's interval. + fee_type = create_fee_type(%{interval: :yearly}, actor) + member = create_member(fee_type, actor) + cycle_start = ~D[2024-02-01] + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: cycle_start, + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id, + interval: :monthly + }) + |> Ash.create!(actor: actor) + + assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly) + end + end + + describe "backfill" do + test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do + fee_type = create_fee_type(%{interval: :quarterly}, actor) + member = create_member(fee_type, actor) + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: ~D[2024-04-01], + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + |> Ash.create!(actor: actor) + + # Simulate a pre-existing row that predates the cycle_end column. + Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL") + + Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [ + Ecto.UUID.dump!(cycle.id) + ]) + + assert {:ok, count} = CycleEndBackfill.run() + assert count >= 1 + + reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor) + assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) + end + end +end diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs deleted file mode 100644 index b93acccb..00000000 --- a/test/mv_web/components/member_filter_component_test.exs +++ /dev/null @@ -1,660 +0,0 @@ -defmodule MvWeb.Components.MemberFilterComponentTest do - @moduledoc """ - Unit tests for the MemberFilterComponent. - - Tests cover: - - Rendering Payment Filter and Boolean Custom Fields - - Boolean filter selection and event emission - - Button label and badge logic - - Filtering to show only boolean custom fields - """ - # Kept async: false. The deferrable-FK migration removed the concurrent - # create_member deadlock, but this file additionally showed an async-isolation - # failure under load (filtered members from a parallel test leaking in), so it - # is not trivially async-safe; resolving that is a separate follow-up. - use MvWeb.ConnCase, async: false - - use Gettext, backend: MvWeb.Gettext - - import Phoenix.LiveViewTest - - alias Mv.Membership.CustomField - - # Helper to create a boolean custom field (uses system_actor - only admin can create) - defp create_boolean_custom_field(attrs \\ %{}) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - default_attrs = %{ - name: "test_boolean_#{System.unique_integer([:positive])}", - value_type: :boolean - } - - attrs = Map.merge(default_attrs, attrs) - - CustomField - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) - end - - # Helper to create a non-boolean custom field (uses system_actor - only admin can create) - defp create_string_custom_field(attrs \\ %{}) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - default_attrs = %{ - name: "test_string_#{System.unique_integer([:positive])}", - value_type: :string - } - - attrs = Map.merge(default_attrs, attrs) - - CustomField - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) - end - - describe "rendering" do - test "trigger carries a trailing chevron affordance", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # Mirror the shared dropdown affordance: a trailing chevron inside the - # bespoke filter trigger button. - chevron = - html - |> LazyHTML.from_fragment() - |> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down)) - - assert Enum.count(chevron) == 1 - end - - test "renders boolean custom fields when present", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Active Member"}) - - {:ok, view, _html} = live(conn, "/members") - - # Should show the boolean custom field name in the dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - assert html =~ boolean_field.name - end - - test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - _boolean_field = create_boolean_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - # Should have both "Payments" and "Custom Fields" group labels - assert html =~ gettext("Payments") || html =~ "Payment" - assert html =~ gettext("Individual datafields") - end - - test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - # Create a non-boolean field to ensure it's not shown - _string_field = create_string_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Component should exist with correct ID - assert has_element?(view, "#member-filter") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - - # Should show payment filter options (check both English and translated) - assert html =~ "All" || html =~ gettext("All") - assert html =~ "Paid" || html =~ gettext("Paid") - assert html =~ "Unpaid" || html =~ gettext("Unpaid") - - # Should not show any boolean field names (since none exist) - # We can't easily check this without knowing field names, but the structure should be correct - end - end - - describe "boolean filter selection" do - test "selecting boolean filter sends correct event", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Newsletter"}) - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Select "True" option for the boolean field using radio input - # Radio inputs trigger phx-change on the form, so we use render_change on the form - view - |> form("#member-filter form", %{ - "custom_boolean" => %{to_string(boolean_field.id) => "true"} - }) - |> render_change() - - # The event should be sent to the parent LiveView - # We verify this by checking that the URL is updated - assert_patch(view) - end - - test "payment filter still works after component extension", %{conn: conn} do - conn = conn_with_oidc_user(conn) - _boolean_field = create_boolean_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Select "Paid" option using radio input - # Radio inputs trigger phx-change on the form, so we use render_change on the form - view - |> form("#member-filter form", %{"payment_filter" => "paid"}) - |> render_change() - - # URL should be updated with cycle_status_filter=paid - path = assert_patch(view) - assert path =~ "cycle_status_filter=paid" - end - end - - describe "button label" do - test "shows active boolean filter names in button label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field1 = create_boolean_custom_field(%{name: "Active Member"}) - boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"}) - - # Set filters via URL - {:ok, view, _html} = - live( - conn, - "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" - ) - - # Component should exist - assert has_element?(view, "#member-filter") - - # Button label should contain the custom field names - # The exact format depends on implementation, but should show active filters - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name - end - - test "truncates long custom field names in button label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - # Create field with very long name (>30 characters) - long_name = String.duplicate("A", 50) - boolean_field = create_boolean_custom_field(%{name: long_name}) - - # Set filter via URL - {:ok, view, _html} = - live(conn, "/members?bf_#{boolean_field.id}=true") - - # Component should exist - assert has_element?(view, "#member-filter") - - # Get button label text - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # Button label should be truncated - full name should NOT appear in button - # (it may appear in dropdown when opened, but not in the button label itself) - # Check that button doesn't contain the full 50-character name - refute button_html =~ long_name - - # Button should still contain some text (truncated version or indicator) - assert String.length(button_html) > 0 - end - - test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?ed_mode=all") - - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # The idle label must not appear; some non-idle label is shown. This is - # the same observable contract as the other filter categories — the - # button visually communicates "a filter is active". The `btn-active` - # CSS class is set by the parent class= attribute but the `<.button>` - # core component currently composes its own class string and drops the - # caller-supplied one — that is a pre-existing component constraint, not - # specific to date filters. - refute button_html =~ gettext("Apply filters") - end - - test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15") - - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - refute button_html =~ gettext("Apply filters") - end - - test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Newsletter"}) - - {:ok, view, _html} = - live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true") - - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # With two distinct filter categories active, the label switches to the - # pluralized "N filters active" form. Without counting date filters as - # a category, this would show only "1 filter active" or the boolean - # field name. - assert button_html =~ "2" - assert button_html =~ gettext("filters active") - end - end - - describe "badge" do - test "shows total count of active boolean filters in badge", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) - boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) - - # Set two filters via URL - {:ok, view, _html} = - live( - conn, - "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" - ) - - # Component should exist - assert has_element?(view, "#member-filter") - - # Badge should be visible when boolean filters are active - assert has_element?(view, "#member-filter .badge") - - # Badge should show count of active boolean filters (2 in this case) - badge_html = - view - |> element("#member-filter .badge") - |> render() - - assert badge_html =~ "2" - end - end - - describe "filtering" do - test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Boolean Field"}) - _string_field = create_string_custom_field(%{name: "String Field"}) - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Should show boolean field in the dropdown panel - # Extract only the dropdown panel HTML to check - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - # Should show boolean field in dropdown - assert dropdown_html =~ boolean_field.name - - # Should not show string field name in the filter dropdown - # (String fields should not appear in boolean filter section) - refute dropdown_html =~ "String Field" - end - - test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - assert dropdown_html =~ gettext("Dates") - assert dropdown_html =~ gettext("Join date") - assert dropdown_html =~ gettext("Exit date") - # Exit-date segmented control modes. - assert dropdown_html =~ gettext("Active only") - assert dropdown_html =~ gettext("Inactive only") - # Built-in date inputs (always present for join_date and the ed_mode selector). - assert dropdown_html =~ ~s(name="jd_from") - assert dropdown_html =~ ~s(name="jd_to") - assert dropdown_html =~ ~s(name="ed_mode") - end - - test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?ed_mode=custom") - - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - assert dropdown_html =~ ~s(name="ed_from") - assert dropdown_html =~ ~s(name="ed_to") - end - - test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)", - %{conn: conn} do - # DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's - # `<.input>` wrapper rather than emitting raw `` tags carrying - # DaisyUI component classes (e.g. `input input-sm input-bordered`) - # directly in HEEX. `<.input>` is the project's single source of truth - # for input styling; bypassing it splits styling across many call sites. - # - # The recognizable structural fingerprint of `<.input>` is a wrapping - # `
    ` `