feat(overview): rework filter builder with editable chips and native date ranges
Editable filter chips, uniformly-sized value controls, working field-picker search, and native date-range inputs with relative presets in place of the vendored calendar — native inputs give year-jump, manual entry and keyboard accessibility for free.
This commit is contained in:
parent
c0d1550401
commit
8303a39041
23 changed files with 1905 additions and 565 deletions
|
|
@ -941,6 +941,21 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Payment-badge tooltip. A native Popover-API element (opened by the
|
||||
* HoverPopover hook), so it lives in the browser TOP LAYER and is not clipped by
|
||||
* the members table's overflow. Placement is pure CSS anchor positioning: the
|
||||
* badge carries `anchor-name`, this popover carries `position-anchor`, and
|
||||
* `position-area` drops it just below the badge, left-aligned. A `position-try`
|
||||
* fallback flips it above the badge when there is no room below. No JS
|
||||
* coordinate math is involved (unlike the older SortTooltip).
|
||||
*/
|
||||
.payment-tip {
|
||||
margin: 0;
|
||||
position-area: bottom span-right;
|
||||
position-try-fallbacks: flip-block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vertically center the row checkbox in the sticky first column using the
|
||||
* table-native vertical-align: middle. This keeps display: table-cell intact so
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {Socket} from "phoenix"
|
|||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import Sortable from "../vendor/sortable"
|
||||
import FilterComboboxSearch from "./hooks/filter_combobox_search"
|
||||
import HoverPopover from "./hooks/hover_popover"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
|
|
@ -36,6 +38,11 @@ function getBrowserTimezone() {
|
|||
// Hooks for LiveView components
|
||||
let Hooks = {}
|
||||
|
||||
// Add-filter builder hooks (member overview field-picker search).
|
||||
Hooks.FilterComboboxSearch = FilterComboboxSearch
|
||||
// Top-layer hover/focus popover (member overview payment badge tooltip).
|
||||
Hooks.HoverPopover = HoverPopover
|
||||
|
||||
// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS
|
||||
// property, not an HTML attribute, so it cannot be set from the server render.
|
||||
// Mirror it from the data-indeterminate attribute on mount and every update
|
||||
|
|
|
|||
33
assets/js/hooks/filter_combobox_search.js
Normal file
33
assets/js/hooks/filter_combobox_search.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// FilterComboboxSearch hook: the type-ahead search input of the add-filter
|
||||
// field picker (§1.21).
|
||||
//
|
||||
// Responsibilities:
|
||||
// * Autofocus the input when the picker opens, so the user can type
|
||||
// immediately (the cursor lands in the search field on mount). Programmatic
|
||||
// .focus() does NOT trigger the browser's :focus-visible state, so no focus
|
||||
// ring is drawn on open — the ring appears only during keyboard navigation,
|
||||
// which is exactly the desired behaviour (focus-visible on keyboard only).
|
||||
// * Guard Enter: while the listbox is open, Enter must not submit the
|
||||
// surrounding form (this replaces the old standalone ComboBox hook).
|
||||
//
|
||||
// The actual field-list filtering is done server-side (phx-change pushes the
|
||||
// query; the component narrows the descriptor list), so this hook only manages
|
||||
// focus and the Enter guard.
|
||||
const FilterComboboxSearch = {
|
||||
mounted() {
|
||||
// Defer to the next frame so focus lands after the dropdown is laid out.
|
||||
requestAnimationFrame(() => this.el.focus())
|
||||
|
||||
this.handleKeyDown = (e) => {
|
||||
const open = this.el.getAttribute("aria-expanded") === "true"
|
||||
if (e.key === "Enter" && open) e.preventDefault()
|
||||
}
|
||||
this.el.addEventListener("keydown", this.handleKeyDown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
export default FilterComboboxSearch
|
||||
76
assets/js/hooks/hover_popover.js
Normal file
76
assets/js/hooks/hover_popover.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// HoverPopover hook: opens a native Popover-API element on hover/keyboard focus
|
||||
// of its trigger, so the popover renders in the browser TOP LAYER and escapes
|
||||
// any ancestor `overflow` clipping (the members table forces overflow-y:auto,
|
||||
// which clips an absolutely-positioned daisyUI tooltip/dropdown-content). Unlike
|
||||
// the older SortTooltip, placement is handed to CSS anchor positioning
|
||||
// (`anchor-name` on the trigger, `position-anchor`/`position-area` on the
|
||||
// popover) — this hook does no coordinate math, it only toggles visibility.
|
||||
//
|
||||
// WCAG 2.2 AA / SC 1.4.13 (Content on Hover or Focus): the popover is shown on
|
||||
// both hover and focus, is dismissible via Escape, and is hoverable/persistent —
|
||||
// a short close delay plus the popover's own hover keep it open while the pointer
|
||||
// travels from the trigger onto the popover content.
|
||||
//
|
||||
// Expected DOM:
|
||||
// <a phx-hook="HoverPopover" data-popover-target="tip-id" style="anchor-name:--x">…</a>
|
||||
// <div id="tip-id" popover style="position-anchor:--x">…</div>
|
||||
const HoverPopover = {
|
||||
mounted() {
|
||||
this.tip = document.getElementById(this.el.dataset.popoverTarget)
|
||||
// Feature-detect: without Popover API support fall back to no visual popover
|
||||
// (the trigger's aria-label still conveys the summary to assistive tech).
|
||||
if (!this.tip || typeof this.tip.showPopover !== "function") return
|
||||
|
||||
this.open = () => {
|
||||
try {
|
||||
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
|
||||
} catch (_e) {}
|
||||
}
|
||||
this.close = () => {
|
||||
try {
|
||||
if (this.tip.matches(":popover-open")) this.tip.hidePopover()
|
||||
} catch (_e) {}
|
||||
}
|
||||
this.cancelClose = () => {
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer)
|
||||
this.closeTimer = null
|
||||
}
|
||||
}
|
||||
this.show = () => {
|
||||
this.cancelClose()
|
||||
this.open()
|
||||
}
|
||||
this.scheduleClose = () => {
|
||||
this.cancelClose()
|
||||
this.closeTimer = setTimeout(() => this.close(), 120)
|
||||
}
|
||||
this.onKey = (e) => {
|
||||
if (e.key === "Escape") this.close()
|
||||
}
|
||||
|
||||
this.el.addEventListener("mouseenter", this.show)
|
||||
this.el.addEventListener("focus", this.show)
|
||||
this.el.addEventListener("mouseleave", this.scheduleClose)
|
||||
this.el.addEventListener("blur", this.scheduleClose)
|
||||
this.el.addEventListener("keydown", this.onKey)
|
||||
// Keep it open while the pointer is over the popover itself (hoverable).
|
||||
this.tip.addEventListener("mouseenter", this.show)
|
||||
this.tip.addEventListener("mouseleave", this.scheduleClose)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (!this.tip) return
|
||||
this.cancelClose()
|
||||
this.close()
|
||||
this.el.removeEventListener("mouseenter", this.show)
|
||||
this.el.removeEventListener("focus", this.show)
|
||||
this.el.removeEventListener("mouseleave", this.scheduleClose)
|
||||
this.el.removeEventListener("blur", this.scheduleClose)
|
||||
this.el.removeEventListener("keydown", this.onKey)
|
||||
this.tip.removeEventListener("mouseenter", this.show)
|
||||
this.tip.removeEventListener("mouseleave", this.scheduleClose)
|
||||
}
|
||||
}
|
||||
|
||||
export default HoverPopover
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -472,6 +472,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
%{
|
||||
payment_filter: nil,
|
||||
payment_period: PaymentAging.default_period(),
|
||||
suspended: false,
|
||||
stichtag: nil,
|
||||
group_filters: %{},
|
||||
fee_type_filters: %{},
|
||||
boolean_filters: %{},
|
||||
|
|
@ -481,6 +483,46 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)
|
||||
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({:stichtag_changed, stichtag}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:stichtag, stichtag)
|
||||
|> load_members()
|
||||
|> scroll_list_to_top()
|
||||
|> update_selection_assigns()
|
||||
|
||||
query_params =
|
||||
build_query_params(opts_for_query_params(socket, %{stichtag: stichtag}))
|
||||
|> 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({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
|
||||
updated_filters =
|
||||
|
|
@ -595,6 +637,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> 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(:stichtag, Map.get(opts, :stichtag))
|
||||
|> load_members()
|
||||
|> scroll_list_to_top()
|
||||
|> update_selection_assigns()
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@
|
|||
date_filters={@date_filters}
|
||||
payment_filter={@payment_filter}
|
||||
payment_period={@payment_period}
|
||||
suspended={@suspended}
|
||||
stichtag={@stichtag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -471,7 +473,7 @@
|
|||
/>
|
||||
<span
|
||||
:if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
|
||||
class="badge badge-soft badge-info badge-sm shrink-0"
|
||||
class="badge badge-soft badge-primary badge-sm font-normal shrink-0"
|
||||
title={PaymentAging.period_tooltip(@payment_period)}
|
||||
aria-label={PaymentAging.period_tooltip(@payment_period)}
|
||||
data-testid="payment-period-badge"
|
||||
|
|
@ -485,20 +487,28 @@
|
|||
<% count = member.unpaid_cycle_count || 0 %>
|
||||
<% badge = PaymentAging.badge(count) %>
|
||||
<% tip_id = "payment-tip-#{member.id}" %>
|
||||
<button
|
||||
type="button"
|
||||
class={["badge badge-soft badge-md cursor-pointer", badge.color_class]}
|
||||
data-variant={badge.variant}
|
||||
phx-hook={count > 0 && "SortTooltip"}
|
||||
<% 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}"}
|
||||
data-tooltip-id={count > 0 && tip_id}
|
||||
phx-click={JS.navigate(~p"/members/#{member.id}?tab=membership_fees")}
|
||||
class={["badge badge-soft badge-md", badge.color_class]}
|
||||
style={count > 0 && "anchor-name: #{anchor}"}
|
||||
data-variant={badge.variant}
|
||||
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}
|
||||
</button>
|
||||
</.link>
|
||||
<%= if count > 0 do %>
|
||||
<% cycles = PaymentAging.open_cycles(member, @payment_period) %>
|
||||
<% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
|
||||
|
|
@ -506,7 +516,8 @@
|
|||
id={tip_id}
|
||||
popover="manual"
|
||||
role="tooltip"
|
||||
class="m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
|
||||
style={"position-anchor: #{anchor}"}
|
||||
class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
|
||||
data-testid="payment-tooltip"
|
||||
>
|
||||
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
|
||||
|
|
@ -521,7 +532,8 @@
|
|||
</li>
|
||||
<li :if={overflow > 0} class="flex">
|
||||
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
|
||||
{gettext("+%{count} more", count: overflow)}
|
||||
<.icon name="hero-plus-circle" class="size-3.5" />
|
||||
{gettext("%{count} more", count: overflow)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ defmodule MvWeb.MemberLive.Index.DatePresets do
|
|||
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()]
|
||||
@spec all() :: [preset(), ...]
|
||||
def all, do: @presets
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -101,7 +101,12 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do
|
|||
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: :exit_date}
|
||||
%{
|
||||
key: :exit_date,
|
||||
group: :membership,
|
||||
label: gettext("Exit date"),
|
||||
control: :active_former
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
(at least N unpaid cycles in the period)
|
||||
* anything else → `nil` (no payment-count filter)
|
||||
"""
|
||||
@spec parse_filter(term()) :: 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}
|
||||
|
|
@ -212,17 +212,31 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
|
||||
@doc """
|
||||
Human-readable tooltip naming the active payment period with locally formatted
|
||||
(`dd.MM.yyyy`) bounds (§1.28). The all-outstanding default names all
|
||||
outstanding cycles; open-ended periods render an open bound as an ellipsis.
|
||||
(`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("Fees across all outstanding cycles")
|
||||
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("Fees in period %{from}–%{to}", from: period_bound(from), to: period_bound(to))
|
||||
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("Fees across all outstanding cycles")
|
||||
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: "…"
|
||||
|
|
|
|||
|
|
@ -89,11 +89,6 @@ msgstr "Aktionen"
|
|||
msgid "Active members"
|
||||
msgstr "Aktive Mitglieder"
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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"
|
||||
|
|
@ -1244,7 +1239,6 @@ msgstr "Beispiele"
|
|||
msgid "Exit Date"
|
||||
msgstr "Austrittsdatum"
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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
|
||||
|
|
@ -2725,11 +2719,6 @@ msgstr "Vierteljährlich"
|
|||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
||||
msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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"
|
||||
|
|
@ -4102,7 +4091,6 @@ msgstr "Aktiv / ehemalig"
|
|||
msgid "Group"
|
||||
msgstr "Gruppe"
|
||||
|
||||
#: 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 "Payment"
|
||||
|
|
@ -4168,16 +4156,6 @@ msgstr "Filtern nach …"
|
|||
msgid "Former"
|
||||
msgstr "Ehemalig"
|
||||
|
||||
#: 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 "Group: %{name}"
|
||||
|
|
@ -4188,21 +4166,6 @@ msgstr "Gruppe: %{name}"
|
|||
msgid "Group: not %{name}"
|
||||
msgstr "Gruppe: nicht %{name}"
|
||||
|
||||
#: 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 ≥ 2"
|
||||
msgstr "Offen ≥ 2"
|
||||
|
||||
#: 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 "Joined"
|
||||
|
|
@ -4223,21 +4186,6 @@ msgstr "Mitgliedschaft"
|
|||
msgid "Payment status"
|
||||
msgstr "Zahlungsstatus"
|
||||
|
||||
#: 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 "Quick"
|
||||
|
|
@ -4268,11 +4216,7 @@ msgstr "≥ %{n} offen"
|
|||
msgid "%{count} open"
|
||||
msgstr "%{count} offen"
|
||||
|
||||
#: 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
|
||||
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All paid"
|
||||
|
|
@ -4363,12 +4307,152 @@ msgstr "Letzte 7 Tage"
|
|||
msgid "This quarter"
|
||||
msgstr "Dieses Quartal"
|
||||
|
||||
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fees across all outstanding cycles"
|
||||
msgstr "Beiträge über alle offenen Zyklen"
|
||||
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 "Fees in period %{from}–%{to}"
|
||||
msgstr "Beiträge im Zeitraum %{from}–%{to}"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -90,11 +90,6 @@ msgstr ""
|
|||
msgid "Active members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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"
|
||||
|
|
@ -1245,7 +1240,6 @@ msgstr ""
|
|||
msgid "Exit Date"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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
|
||||
|
|
@ -2726,11 +2720,6 @@ msgstr ""
|
|||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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"
|
||||
|
|
@ -4102,7 +4091,6 @@ msgstr ""
|
|||
msgid "Group"
|
||||
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 "Payment"
|
||||
|
|
@ -4168,16 +4156,6 @@ msgstr ""
|
|||
msgid "Former"
|
||||
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
|
||||
msgid "Group: %{name}"
|
||||
|
|
@ -4188,21 +4166,6 @@ msgstr ""
|
|||
msgid "Group: not %{name}"
|
||||
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 ≥ 2"
|
||||
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 "Joined"
|
||||
|
|
@ -4223,21 +4186,6 @@ msgstr ""
|
|||
msgid "Payment status"
|
||||
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 "Quick"
|
||||
|
|
@ -4268,11 +4216,7 @@ msgstr ""
|
|||
msgid "%{count} open"
|
||||
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
|
||||
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All paid"
|
||||
|
|
@ -4363,12 +4307,77 @@ msgstr ""
|
|||
msgid "This quarter"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fees across all outstanding cycles"
|
||||
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 "Fees in period %{from}–%{to}"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -90,11 +90,6 @@ msgstr ""
|
|||
msgid "Active members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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"
|
||||
|
|
@ -1245,7 +1240,6 @@ msgstr ""
|
|||
msgid "Exit Date"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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
|
||||
|
|
@ -2726,11 +2720,6 @@ msgstr ""
|
|||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_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"
|
||||
|
|
@ -3956,6 +3945,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 ""
|
||||
|
|
@ -4101,7 +4091,6 @@ msgstr ""
|
|||
msgid "Group"
|
||||
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 "Payment"
|
||||
|
|
@ -4167,16 +4156,6 @@ msgstr ""
|
|||
msgid "Former"
|
||||
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
|
||||
msgid "Group: %{name}"
|
||||
|
|
@ -4187,21 +4166,6 @@ msgstr ""
|
|||
msgid "Group: not %{name}"
|
||||
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 ≥ 2"
|
||||
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 "Joined"
|
||||
|
|
@ -4212,11 +4176,6 @@ msgstr ""
|
|||
msgid "Last 30 days"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership"
|
||||
|
|
@ -4227,21 +4186,6 @@ msgstr ""
|
|||
msgid "Payment status"
|
||||
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 "Quick"
|
||||
|
|
@ -4272,11 +4216,7 @@ msgstr ""
|
|||
msgid "%{count} open"
|
||||
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
|
||||
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "All paid"
|
||||
|
|
@ -4307,11 +4247,6 @@ msgstr ""
|
|||
msgid "Fees"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fees · %{range}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "January"
|
||||
|
|
@ -4347,11 +4282,166 @@ msgstr ""
|
|||
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"
|
||||
|
|
@ -4362,6 +4452,26 @@ msgstr ""
|
|||
#~ 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}"
|
||||
|
|
@ -4371,3 +4481,13 @@ msgstr ""
|
|||
#~ #, 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 ""
|
||||
|
|
|
|||
|
|
@ -61,4 +61,96 @@ defmodule MvWeb.MemberLive.IndexActiveFormerTest do
|
|||
assert has_element?(view, "#row-#{ctx.future.id}")
|
||||
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||
end
|
||||
|
||||
describe "Stichtag paragraph (§1.24)" do
|
||||
test "the active/former control carries a separate 'active on reference date' input", ctx do
|
||||
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||
|
||||
pick_field(view, "active_former")
|
||||
|
||||
# The Stichtag input sits in its own paragraph, not inside the
|
||||
# Active/Former button-join.
|
||||
assert has_element?(view, "[data-testid='stichtag-paragraph'] input[name='stichtag']")
|
||||
refute has_element?(view, ".join input[name='stichtag']")
|
||||
end
|
||||
|
||||
test "setting a reference date activates the point-in-time membership filter", ctx do
|
||||
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||
|
||||
pick_field(view, "active_former")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-active-former']")
|
||||
|> render_change(%{"quick" => "active", "stichtag" => "2024-06-15"})
|
||||
|
||||
_ = render(view)
|
||||
path = assert_patch(view)
|
||||
assert path =~ "stichtag=2024-06-15"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Eigener Zeitraum exit-date range (§1.24)" do
|
||||
test "the exit-date control carries a separate native From/To range paragraph", ctx do
|
||||
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||
|
||||
pick_field(view, "active_former")
|
||||
|
||||
# Third paragraph, same native two-input range control the join-date
|
||||
# builder uses — separate from the Active/Former join and the Stichtag box.
|
||||
assert has_element?(view, "[data-testid='value-control-exit-range']")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='value-control-exit-range'] input[type='date'][name='ed_from']"
|
||||
)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='value-control-exit-range'] input[type='date'][name='ed_to']"
|
||||
)
|
||||
end
|
||||
|
||||
test "typing an exit-date range commits ed_mode=custom with bounds and filters to it", ctx do
|
||||
from = Date.add(Date.utc_today(), -60)
|
||||
to = Date.add(Date.utc_today(), -1)
|
||||
|
||||
{:ok, view, _html} = live(ctx.conn, ~p"/members")
|
||||
|
||||
pick_field(view, "active_former")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-exit-range']")
|
||||
|> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
|
||||
|
||||
_ = render(view)
|
||||
path = assert_patch(view)
|
||||
assert path =~ "ed_mode=custom"
|
||||
assert path =~ "ed_from=#{Date.to_iso8601(from)}"
|
||||
assert path =~ "ed_to=#{Date.to_iso8601(to)}"
|
||||
|
||||
# Only the member whose exit date falls within [from, to] is shown.
|
||||
assert has_element?(view, "#row-#{ctx.former.id}")
|
||||
refute has_element?(view, "#row-#{ctx.active.id}")
|
||||
refute has_element?(view, "#row-#{ctx.future.id}")
|
||||
end
|
||||
|
||||
test "an exit-date range preserves an existing join-date filter", ctx do
|
||||
from = Date.add(Date.utc_today(), -60)
|
||||
to = Date.add(Date.utc_today(), -1)
|
||||
|
||||
{:ok, view, _html} = live(ctx.conn, ~p"/members?jd_from=2020-01-01")
|
||||
|
||||
pick_field(view, "active_former")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-exit-range']")
|
||||
|> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
|
||||
|
||||
_ = render(view)
|
||||
path = assert_patch(view)
|
||||
# The exit range must not wipe the pre-existing join-date bound.
|
||||
assert path =~ "jd_from=2020-01-01"
|
||||
assert path =~ "ed_mode=custom"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
91
test/mv_web/member_live/index_chip_edit_test.exs
Normal file
91
test/mv_web/member_live/index_chip_edit_test.exs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule MvWeb.MemberLive.IndexChipEditTest do
|
||||
@moduledoc """
|
||||
§1.18 — clicking an applied-filter chip body re-opens that filter's value
|
||||
control pre-filled with the current value; a changed selection re-commits and
|
||||
updates the chip; the × still removes the filter.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Group
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
|
||||
|
||||
%{conn: conn_with_oidc_user(conn), group: group}
|
||||
end
|
||||
|
||||
test "clicking the chip body opens the value control pre-filled with the current value", %{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||
|
||||
refute has_element?(view, "[data-testid='value-control-group']")
|
||||
|
||||
view |> element("[data-testid='filter-chip-edit']") |> render_click()
|
||||
|
||||
# The group control opens with this group's "is" (in) operator pre-selected.
|
||||
assert has_element?(view, "[data-testid='value-control-group']")
|
||||
assert has_element?(view, "input[name='group_#{group.id}'][value='in'][checked]")
|
||||
end
|
||||
|
||||
test "the value control opens anchored under the edited chip, not as a new pending chip", %{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||
|
||||
view |> element("[data-testid='filter-chip-edit']") |> render_click()
|
||||
|
||||
# §1.18: the control is a descendant of the chip's own list item (anchored in
|
||||
# place under that chip), and no separate pending chip is spun up at the row.
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='filter-chip-item'] [data-testid='value-control-group']"
|
||||
)
|
||||
|
||||
refute has_element?(view, "[data-testid='pending-chip']")
|
||||
refute has_element?(view, "[data-testid='pending-filter']")
|
||||
end
|
||||
|
||||
test "changing the value from the re-opened control re-commits and updates the URL", %{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||
|
||||
view |> element("[data-testid='filter-chip-edit']") |> render_click()
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-group']")
|
||||
|> render_change(%{"group_#{group.id}" => "not_in"})
|
||||
|
||||
_ = render(view)
|
||||
path = assert_patch(view)
|
||||
assert path =~ "group_#{group.id}=not_in"
|
||||
end
|
||||
|
||||
test "the × still removes the filter", %{conn: conn, group: group} do
|
||||
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||
|
||||
view |> element("[data-testid='filter-chip-remove']") |> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
refute path =~ "group_#{group.id}"
|
||||
refute has_element?(view, "[data-testid='filter-chip']")
|
||||
end
|
||||
end
|
||||
74
test/mv_web/member_live/index_date_range_test.exs
Normal file
74
test/mv_web/member_live/index_date_range_test.exs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
defmodule MvWeb.MemberLive.IndexDateRangeTest do
|
||||
@moduledoc """
|
||||
§1.25 / §3.12 (native-first) — the date-range value control combines the
|
||||
relative presets with a separate "Eigener Zeitraum" (custom range) paragraph
|
||||
holding two native `<input type="date">` fields (Von/Bis). cally is removed.
|
||||
|
||||
Picking a preset commits a concrete range; typing into the native Von/Bis
|
||||
inputs commits the custom range through the shared date-filter dispatch.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Range", last_name: "Member", email: "range@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
%{conn: conn_with_oidc_user(conn)}
|
||||
end
|
||||
|
||||
test "the join-date control shows presets and native Von/Bis date inputs", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "join_date")
|
||||
|
||||
assert has_element?(view, "[data-testid='value-control-date-range']")
|
||||
assert has_element?(view, "[data-testid='date-presets']")
|
||||
# Separate "Eigener Zeitraum" paragraph with two native date inputs side by side.
|
||||
assert has_element?(view, "[data-testid='custom-range']")
|
||||
assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_from']")
|
||||
assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_to']")
|
||||
end
|
||||
|
||||
test "cally is fully removed from the date-range control", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "join_date")
|
||||
|
||||
refute has_element?(view, "[data-testid='date-range-cally']")
|
||||
refute has_element?(view, "calendar-range")
|
||||
refute has_element?(view, "[phx-hook='DateRangeCally']")
|
||||
end
|
||||
|
||||
test "picking a preset commits a concrete range to the URL", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "join_date")
|
||||
view |> element("[data-testid='date-preset-this_year']") |> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
today = Date.utc_today()
|
||||
assert path =~ "jd_from=#{today.year}-01-01"
|
||||
assert path =~ "jd_to=#{Date.to_iso8601(today)}"
|
||||
end
|
||||
|
||||
test "typing a native custom range commits it to the URL", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "join_date")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-date-range']")
|
||||
|> render_change(%{"jd_from" => "2024-03-01", "jd_to" => "2024-03-31"})
|
||||
|
||||
path = assert_patch(view)
|
||||
assert path =~ "jd_from=2024-03-01"
|
||||
assert path =~ "jd_to=2024-03-31"
|
||||
end
|
||||
end
|
||||
48
test/mv_web/member_live/index_filter_row_test.exs
Normal file
48
test/mv_web/member_live/index_filter_row_test.exs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule MvWeb.MemberLive.IndexFilterRowTest do
|
||||
@moduledoc """
|
||||
§1.19 — the filter row renders "+ Add filter", the applied-filter chips and
|
||||
"Clear all" as one uniform button size (all share the `btn-sm` sizing), and
|
||||
each chip is composed of real, focusable buttons.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Group
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Row", last_name: "Member", email: "row@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
|
||||
|
||||
%{conn: conn_with_oidc_user(conn), group: group}
|
||||
end
|
||||
|
||||
test "add-filter trigger, chips and clear-all share the uniform btn-sm size", %{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
|
||||
|
||||
assert has_element?(view, "[data-testid='add-filter-trigger'].btn-sm")
|
||||
assert has_element?(view, "[data-testid='clear-all-filters'].btn-sm")
|
||||
# The chip body and its remove control are real, focusable buttons at btn-sm.
|
||||
assert has_element?(view, "button[data-testid='filter-chip-edit'].btn-sm")
|
||||
assert has_element?(view, "button[data-testid='filter-chip-remove'].btn-sm")
|
||||
|
||||
# The join seam must be a single hairline: the remove button drops its own
|
||||
# left border so the body's right border is the only one at the seam (no
|
||||
# doubled 1px border that sub-pixel-rounds to 1px/2px and flips on zoom).
|
||||
assert has_element?(view, "button[data-testid='filter-chip-remove'].border-l-0")
|
||||
end
|
||||
end
|
||||
|
|
@ -57,7 +57,7 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
assert html =~ "Paid"
|
||||
end
|
||||
|
||||
test "the badge is a focusable control that drills into the member fees history", %{
|
||||
test "the badge is a real navigation link that drills into the member fees history", %{
|
||||
conn: conn
|
||||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
|
@ -66,13 +66,33 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
|||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"button[data-testid='payment-badge']##{"payment-badge-#{member.id}"}"
|
||||
)
|
||||
badge = "a[data-testid='payment-badge']#payment-badge-#{member.id}"
|
||||
href = "/members/#{member.id}?tab=membership_fees"
|
||||
|
||||
# A tooltip popover lists the open cycles for the period.
|
||||
assert has_element?(view, "##{"payment-tip-#{member.id}"}[popover]")
|
||||
# A semantic <a> navigating to the member's membership-fees section — not a
|
||||
# div with JS.navigate — so it is keyboard- and right-click-friendly.
|
||||
assert has_element?(view, "#{badge}[href='#{href}']")
|
||||
|
||||
# Its hover/focus tooltip is a top-layer native Popover opened via the
|
||||
# HoverPopover hook (no SortTooltip id-linking on the badge anymore).
|
||||
assert has_element?(view, "#{badge}[phx-hook='HoverPopover']")
|
||||
assert has_element?(view, "#{badge}[data-popover-target='payment-tip-#{member.id}']")
|
||||
refute has_element?(view, "[data-testid='payment-badge'][data-tooltip-id]")
|
||||
|
||||
# The tooltip popover lists the open cycles for the period.
|
||||
assert has_element?(view, "#payment-tip-#{member.id}[popover]")
|
||||
end
|
||||
|
||||
test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assert has_element?(view, "a[data-testid='payment-badge']#payment-badge-#{member.id}")
|
||||
refute has_element?(view, "#payment-tip-#{member.id}")
|
||||
refute has_element?(view, "a#payment-badge-#{member.id}[phx-hook]")
|
||||
end
|
||||
|
||||
test "members without cycles render without error", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -75,6 +75,20 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
|
|||
tip = PaymentAging.period_tooltip(%{from: nil, to: nil})
|
||||
assert is_binary(tip) and tip =~ "outstanding"
|
||||
end
|
||||
|
||||
test "explains that the values refer to the filtered contribution period" do
|
||||
# The header badge falls back to a plain "filtered" label whenever no
|
||||
# compact short code fits (arbitrary or open-ended ranges); its tooltip
|
||||
# must spell out what "filtered" means, not just repeat the range.
|
||||
for period <- [
|
||||
%{from: nil, to: nil},
|
||||
%{from: ~D[2026-02-03], to: ~D[2026-08-17]},
|
||||
%{from: ~D[2026-01-01], to: nil}
|
||||
] do
|
||||
tip = PaymentAging.period_tooltip(period)
|
||||
assert tip =~ "refer to the filtered contribution period"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "payment-count filter codec" do
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
|
|||
badge = view |> element("[data-testid='payment-period-badge']") |> render()
|
||||
assert badge =~ "03.02.2026"
|
||||
assert badge =~ "17.08.2026"
|
||||
assert badge =~ "refer to the filtered contribution period"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1411,9 +1411,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
)
|
||||
|
||||
# Both filters are reflected as chips: the boolean field at "Yes" and the
|
||||
# period-scoped payment filter as "Fully paid".
|
||||
# period-scoped payment filter as "All paid".
|
||||
assert boolean_chip?(view, boolean_field, "Yes")
|
||||
assert render(view) =~ "Fully paid"
|
||||
assert render(view) =~ "All paid"
|
||||
|
||||
# Both should be in URL when triggering search
|
||||
view
|
||||
|
|
|
|||
257
test/mv_web/member_live/index_value_control_test.exs
Normal file
257
test/mv_web/member_live/index_value_control_test.exs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
defmodule MvWeb.MemberLive.IndexValueControlTest do
|
||||
@moduledoc """
|
||||
§1.20 / §1.21 / §1.22 / §1.23 — the reworked value controls of the add-filter
|
||||
builder:
|
||||
|
||||
* no "All"/"Alle" option in the boolean / group value controls (§1.20);
|
||||
* the field picker's search input is a live type-ahead (§1.21);
|
||||
* the payment control reveals a two-sided open-cycle count range under
|
||||
"Has unpaid" and offers a suspended-status option (§1.22, §1.23).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.Group
|
||||
|
||||
setup %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Val", last_name: "Member", email: "val@example.com"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
{:ok, group} =
|
||||
Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
|
||||
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "newsletter_#{System.unique_integer([:positive])}",
|
||||
value_type: :boolean,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
%{conn: conn_with_oidc_user(conn), group: group, field: field}
|
||||
end
|
||||
|
||||
describe "unified popover shell (§3.11)" do
|
||||
test "every value control shares the 'Filter: <field>' shell title", %{
|
||||
conn: conn,
|
||||
field: field
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "group")
|
||||
assert has_element?(view, "[data-testid='control-title']", "Filter:")
|
||||
|
||||
pick_field(view, field.id)
|
||||
assert has_element?(view, "[data-testid='control-title']", "Filter:")
|
||||
|
||||
pick_field(view, "payment")
|
||||
assert has_element?(view, "[data-testid='control-title']", "Filter:")
|
||||
end
|
||||
end
|
||||
|
||||
describe "no All option (§1.20)" do
|
||||
test "the group control offers is / is-not but no 'all' option", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "group")
|
||||
|
||||
assert has_element?(view, "[data-testid='value-control-group'] input[value='in']")
|
||||
assert has_element?(view, "[data-testid='value-control-group'] input[value='not_in']")
|
||||
refute has_element?(view, "[data-testid='value-control-group'] input[value='all']")
|
||||
end
|
||||
|
||||
test "the group operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "group")
|
||||
|
||||
# Same join-toggle presentation as the boolean custom-field control: a
|
||||
# check icon on the ":in" (Ja) option and an x-mark on ":not_in" (Nein),
|
||||
# with a visible selected state.
|
||||
assert has_element?(view, "[data-testid='value-control-group'] .join .hero-check-circle")
|
||||
assert has_element?(view, "[data-testid='value-control-group'] .join .hero-x-circle")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='value-control-group'] label.has-\\[\\:checked\\]\\:btn-primary"
|
||||
)
|
||||
end
|
||||
|
||||
test "the fee-type operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
|
||||
_fee_type = Mv.Fixtures.create_fee_type()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "fee_type")
|
||||
|
||||
assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-check-circle")
|
||||
assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-x-circle")
|
||||
end
|
||||
|
||||
test "the boolean control offers Yes / No but no 'all' option", %{conn: conn, field: field} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, field.id)
|
||||
|
||||
assert has_element?(view, "[data-testid='value-control-boolean'] input[value='true']")
|
||||
assert has_element?(view, "[data-testid='value-control-boolean'] input[value='false']")
|
||||
refute has_element?(view, "[data-testid='value-control-boolean'] input[value='all']")
|
||||
end
|
||||
|
||||
test "the boolean control renders a check-icon Yes and an x-icon No join toggle", %{
|
||||
conn: conn,
|
||||
field: field
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, field.id)
|
||||
|
||||
# Join-toggle with a check icon on Yes and an x-mark icon on No (Task 5).
|
||||
assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-check-circle")
|
||||
assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-x-circle")
|
||||
# A checked option shows a visible selected state (btn-primary via has-[:checked]).
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='value-control-boolean'] label.has-\\[\\:checked\\]\\:btn-primary"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "field picker search (§1.21)" do
|
||||
test "the search input is a live type-ahead hook and narrows the field list", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
open_picker(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='field-combobox'][phx-hook='FilterComboboxSearch']")
|
||||
# The search input must live inside a form carrying the change binding —
|
||||
# LiveView rejects `phx-change` on an input that is not inside a <form>
|
||||
# ("form events require the input to be inside a form"), which silently
|
||||
# broke the live filtering in the browser (§1.21).
|
||||
assert has_element?(view, "form[phx-change='filter_fields'] [data-testid='field-combobox']")
|
||||
|
||||
# A non-matching query removes the Group option from the listbox.
|
||||
view
|
||||
|> form("[data-testid='field-search-form']", %{"picker_query" => "zzzznomatch"})
|
||||
|> render_change()
|
||||
|
||||
refute has_element?(view, "#member-filter-field-opt-group")
|
||||
end
|
||||
end
|
||||
|
||||
describe "payment status labels" do
|
||||
test "the toggle reads 'All paid' / 'Open contributions'", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "payment")
|
||||
|
||||
assert has_element?(view, "[data-testid='value-control-payment']", "All paid")
|
||||
assert has_element?(view, "[data-testid='value-control-payment']", "Open contributions")
|
||||
refute has_element?(view, "[data-testid='value-control-payment']", "Fully paid")
|
||||
refute has_element?(view, "[data-testid='value-control-payment']", "Has unpaid")
|
||||
end
|
||||
|
||||
test "the applied fully-paid chip reads 'All paid'", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/members?pay_filter=fully_paid")
|
||||
|
||||
assert has_element?(view, "[data-testid='filter-chip']", "All paid")
|
||||
refute render(view) =~ "Fully paid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "payment control (§1.22, §1.23)" do
|
||||
test "'Has unpaid' reveals the two-sided open-cycle count range", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "payment")
|
||||
refute has_element?(view, "[data-testid='payment-count-range']")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-payment']")
|
||||
|> render_change(%{
|
||||
"__payment" => "1",
|
||||
"pay" => "has_unpaid",
|
||||
"pay_min" => "1",
|
||||
"pay_max" => ""
|
||||
})
|
||||
|
||||
_ = render(view)
|
||||
assert_patch(view)
|
||||
|
||||
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_min']")
|
||||
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_max']")
|
||||
end
|
||||
|
||||
test "the suspended option activates the suspended-status filter in the URL", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
pick_field(view, "payment")
|
||||
assert has_element?(view, "[data-testid='payment-suspended'] input[name='suspended']")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-payment']")
|
||||
|> render_change(%{"__payment" => "1", "suspended" => "1"})
|
||||
|
||||
_ = render(view)
|
||||
path = assert_patch(view)
|
||||
assert path =~ "suspended=1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "unified date-range controls" do
|
||||
# Every date-range value control (payment period, join date, exit date, and
|
||||
# custom date fields) renders the same shared native Von/Bis row: a
|
||||
# non-wrapping flex row whose two `<input type="date">` are each wrapped in
|
||||
# `min-w-0 flex-1` so they share width equally and stay on one line.
|
||||
test "payment period, join, exit and custom date all render the shared row", %{conn: conn} do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, date_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "birthday_#{System.unique_integer([:positive])}",
|
||||
value_type: :date,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
assert_shared_range(view, "payment", "payment-period-range", "pay_from", "pay_to")
|
||||
assert_shared_range(view, "join_date", "custom-range", "jd_from", "jd_to")
|
||||
assert_shared_range(view, "active_former", "exit-custom-range", "ed_from", "ed_to")
|
||||
|
||||
cd_from = "cdf_#{date_field.id}_from"
|
||||
cd_to = "cdf_#{date_field.id}_to"
|
||||
assert_shared_range(view, date_field.id, "custom-range", cd_from, cd_to)
|
||||
end
|
||||
end
|
||||
|
||||
# Opens the given field's value control and asserts its date-range row is the
|
||||
# shared native Von/Bis layout: a `flex` (non-wrapping) row whose two date
|
||||
# inputs are each wrapped in `min-w-0 flex-1` (equal width, single line).
|
||||
defp assert_shared_range(view, field, testid, from_name, to_name) do
|
||||
pick_field(view, field)
|
||||
|
||||
assert has_element?(view, "[data-testid='#{testid}'].flex")
|
||||
refute has_element?(view, "[data-testid='#{testid}'].flex-wrap")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{from_name}']"
|
||||
)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{to_name}']"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -60,16 +60,23 @@ defmodule MvWeb.FilterBuilderHelpers do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Applies a payment-count filter (and optionally a period) through the builder.
|
||||
`filter` is one of `"all"`, `"fully_paid"`, `"unpaid_1"`, `"unpaid_2"`,
|
||||
`"unpaid_3"`; `from`/`to` are ISO-8601 strings or `""`.
|
||||
Applies a payment status filter (and optionally a period) through the builder.
|
||||
`status` is `"fully_paid"` or `"has_unpaid"`; `from`/`to` are ISO-8601 strings
|
||||
or `""`. For `"has_unpaid"` the open-cycle range defaults to `min` 1 / no max.
|
||||
"""
|
||||
def apply_payment_filter(view, filter, from \\ "", to \\ "") do
|
||||
def apply_payment_filter(view, status, from \\ "", to \\ "", min \\ "1", max \\ "") do
|
||||
pick_field(view, "payment")
|
||||
|
||||
view
|
||||
|> element("[data-testid='value-control-payment']")
|
||||
|> render_change(%{"pay" => filter, "pay_from" => from, "pay_to" => to})
|
||||
|> render_change(%{
|
||||
"__payment" => "1",
|
||||
"pay" => status,
|
||||
"pay_min" => min,
|
||||
"pay_max" => max,
|
||||
"pay_from" => from,
|
||||
"pay_to" => to
|
||||
})
|
||||
|
||||
flush_and_patch(view)
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue