Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2025-12-03 12:52:12 +01:00
commit f0613fe1e5
29 changed files with 1661 additions and 405 deletions

View file

@ -53,6 +53,8 @@ steps:
- mix hex.audit - mix hex.audit
# Provide hints for improving code quality # Provide hints for improving code quality
- mix credo - mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres - name: wait_for_postgres
image: docker.io/library/postgres:17.6 image: docker.io/library/postgres:17.6

View file

@ -12,8 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- PostgreSQL trigram-based member search with typo tolerance - PostgreSQL trigram-based member search with typo tolerance
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support - WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
- Bilingual UI (German/English) for member linking workflow - Bilingual UI (German/English) for member linking workflow
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
- CopyToClipboard JavaScript hook with fallback for older browsers
- Button shows count of visible selected members (respects search/filter)
- German/English translations
### Fixed ### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Email validation false positive when linking user and member with identical emails (#168 Problem #4)
- Relationship data extraction from Ash manage_relationship during validation - Relationship data extraction from Ash manage_relationship during validation
- Copy button count now shows only visible selected members when filtering

View file

@ -29,6 +29,7 @@ lint:
mix format --check-formatted mix format --check-formatted
mix compile --warnings-as-errors mix compile --warnings-as-errors
mix credo mix credo
mix gettext.extract --check-up-to-date
audit: audit:
mix sobelow --config mix sobelow --config

View file

@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
// Hooks for LiveView components // Hooks for LiveView components
let Hooks = {} let Hooks = {}
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
Hooks.CopyToClipboard = {
mounted() {
this.handleEvent("copy_to_clipboard", ({text}) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(err => {
console.error("Clipboard write failed:", err)
})
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea")
textArea.value = text
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand("copy")
} catch (err) {
console.error("Fallback clipboard copy failed:", err)
}
document.body.removeChild(textArea)
}
})
}
}
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown // ComboBox hook: Prevents form submission when Enter is pressed in dropdown
Hooks.ComboBox = { Hooks.ComboBox = {
mounted() { mounted() {

View file

@ -115,7 +115,6 @@ Member (1) → (N) Properties
### Member Constraints ### Member Constraints
- First name and last name required (min 1 char) - First name and last name required (min 1 char)
- Email unique, validated format (5-254 chars) - Email unique, validated format (5-254 chars)
- Birth date cannot be in future
- Join date cannot be in future - Join date cannot be in future
- Exit date must be after join date - Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}` - Phone: `+?[0-9\- ]{6,20}`
@ -169,7 +168,7 @@ Member (1) → (N) Properties
### Weighted Fields ### Weighted Fields
- **Weight A (highest):** first_name, last_name - **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes - **Weight B:** email, notes
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code - **Weight C:** phone_number, city, street, house_number, postal_code
- **Weight D (lowest):** join_date, exit_date - **Weight D (lowest):** join_date, exit_date
### Usage Example ### Usage Example
@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with:
- tokens (jti, purpose, extra_data) - tokens (jti, purpose, extra_data)
**Personal Data (GDPR):** **Personal Data (GDPR):**
- All member fields (name, email, birth_date, address) - All member fields (name, email, address)
- User email - User email
- Token subject - Token subject

View file

@ -122,7 +122,6 @@ Table members {
first_name text [not null, note: 'Member first name (min length: 1)'] first_name text [not null, note: 'Member first name (min length: 1)']
last_name text [not null, note: 'Member last name (min length: 1)'] last_name text [not null, note: 'Member last name (min length: 1)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)'] email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
birth_date date [null, note: 'Date of birth (cannot be in future)']
paid boolean [null, note: 'Payment status flag'] paid boolean [null, note: 'Payment status flag']
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})'] phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
join_date date [null, note: 'Date when member joined club (cannot be in future)'] join_date date [null, note: 'Date when member joined club (cannot be in future)']
@ -153,7 +152,7 @@ Table members {
**Club Member Master Data** **Club Member Master Data**
Core entity for membership management containing: Core entity for membership management containing:
- Personal information (name, birth date, email) - Personal information (name, email)
- Contact details (phone, address) - Contact details (phone, address)
- Membership status (join/exit dates, payment status) - Membership status (join/exit dates, payment status)
- Additional notes - Additional notes
@ -183,7 +182,6 @@ Table members {
**Validation Rules:** **Validation Rules:**
- first_name, last_name: min 1 character - first_name, last_name: min 1 character
- email: 5-254 characters, valid email format - email: 5-254 characters, valid email format
- birth_date: cannot be in future
- join_date: cannot be in future - join_date: cannot be in future
- exit_date: must be after join_date (if both present) - exit_date: must be after join_date (if both present)
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$ - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$

View file

@ -1327,6 +1327,33 @@ end
--- ---
## Session: Bulk Email Copy Feature (2025-12-02)
### Feature Summary
Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard.
**Key Features:**
- Copy button appears only when visible members are selected
- Email format: `First Last <email>` with semicolon separator (email client compatible)
- Button shows count of visible selected members (respects search/filter)
- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers
- Bilingual UI (English/German)
### Key Decisions
1. **Email Format:** "First Last <email>" with semicolon - standard for all major email clients
2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering)
3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard
### Files Changed
- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function
- `lib/mv_web/live/member_live/index.html.heex` - Copy button
- `assets/js/app.js` - CopyToClipboard hook
- `test/mv_web/member_live/index_test.exs` - 9 new tests
- `priv/gettext/de/LC_MESSAGES/default.po` - German translations
---
## Session: User-Member Linking UI Enhancement (2025-01-13) ## Session: User-Member Linking UI Enhancement (2025-01-13)
### Feature Summary ### Feature Summary
@ -1559,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
--- ---
**Document Version:** 1.2 **Document Version:** 1.3
**Last Updated:** 2025-11-27 **Last Updated:** 2025-12-02
**Maintainer:** Development Team **Maintainer:** Development Team
**Status:** Living Document (update as project evolves) **Status:** Living Document (update as project evolves)

View file

@ -65,6 +65,7 @@
- ✅ Sorting by basic fields - ✅ Sorting by basic fields
- ✅ User-Member linking (optional 1:1) - ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member - ✅ Email synchronization between User and Member
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
**Closed Issues:** **Closed Issues:**
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) - ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
@ -99,10 +100,10 @@
**Closed Issues:** **Closed Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) - [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) - [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
**Open Issues:** **Open Issues:**
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:** **Missing Features:**

View file

@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do
- Email format validation (using EctoCommons.EmailValidator) - Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits - Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format) - Postal code format: exactly 5 digits (German format)
- Date validations: birth_date and join_date not in future, exit_date after join_date - Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users - Email uniqueness: prevents conflicts with unlinked users
## Full-Text Search ## Full-Text Search
@ -284,11 +284,6 @@ defmodule Mv.Membership.Member do
end end
end end
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
message: "cannot be in the future"
# Join date not in the future # Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)], where: [present(:join_date)],
@ -351,10 +346,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254 constraints min_length: 5, max_length: 254
end end
attribute :birth_date, :date do
allow_nil? true
end
attribute :paid, :boolean do attribute :paid, :boolean do
allow_nil? true allow_nil? true
end end

View file

@ -134,8 +134,8 @@ defmodule Mv.Membership do
## Parameters ## Parameters
- `settings` - The settings record to update - `settings` - The settings record to update
- `visibility_config` - A map of member field names (atoms) to boolean visibility values - `visibility_config` - A map of member field names (strings) to boolean visibility values
(e.g., `%{street: false, house_number: false}`) (e.g., `%{"street" => false, "house_number" => false}`)
## Returns ## Returns
@ -145,9 +145,9 @@ defmodule Mv.Membership do
## Examples ## Examples
iex> {:ok, settings} = Mv.Membership.get_settings() iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
iex> updated.member_field_visibility iex> updated.member_field_visibility
%{street: false, house_number: false} %{"street" => false, "house_number" => false}
""" """
def update_member_field_visibility(settings, visibility_config) do def update_member_field_visibility(settings, visibility_config) do

View file

@ -10,7 +10,7 @@ defmodule Mv.Membership.Setting do
## Attributes ## Attributes
- `club_name` - The name of the association/club (required, cannot be empty) - `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields - `member_field_visibility` - JSONB map storing visibility configuration for member fields
(e.g., `%{street: false, house_number: false}`). Fields not in the map default to `true`. (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
## Singleton Pattern ## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record. This resource uses a singleton pattern - there should only be one settings record.
@ -32,7 +32,7 @@ defmodule Mv.Membership.Setting do
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
# Update member field visibility # Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
@ -67,43 +67,6 @@ defmodule Mv.Membership.Setting do
description "Updates the visibility configuration for member fields in the overview" description "Updates the visibility configuration for member fields in the overview"
require_atomic? false require_atomic? false
accept [:member_field_visibility] accept [:member_field_visibility]
change fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do
valid_fields = Mv.Constants.member_fields()
# Normalize keys to atoms (JSONB may return string keys)
invalid_keys =
Enum.filter(visibility, fn {key, _value} ->
atom_key =
if is_atom(key) do
key
else
try do
String.to_existing_atom(key)
rescue
ArgumentError -> nil
end
end
atom_key && atom_key not in valid_fields
end)
|> Enum.map(fn {key, _value} -> key end)
if Enum.empty?(invalid_keys) do
changeset
else
Ash.Changeset.add_error(
changeset,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"
)
end
else
changeset
end
end
end end
end end
@ -111,23 +74,39 @@ defmodule Mv.Membership.Setting do
validate present(:club_name), on: [:create, :update] validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update]
# Validate that member_field_visibility map contains only boolean values # Validate member_field_visibility map structure and content
# This allows dynamic fields without hardcoding specific field names
validate fn changeset, _context -> validate fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do if visibility && is_map(visibility) do
invalid_entries = # Validate all values are booleans
invalid_values =
Enum.filter(visibility, fn {_key, value} -> Enum.filter(visibility, fn {_key, value} ->
not is_boolean(value) not is_boolean(value)
end) end)
if Enum.empty?(invalid_entries) do # Validate all keys are valid member fields
:ok valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
else
{:error, invalid_keys =
field: :member_field_visibility, Enum.filter(visibility, fn {key, _value} ->
message: "All values in member_field_visibility must be booleans"} key not in valid_field_strings
end)
|> Enum.map(fn {key, _value} -> key end)
cond do
not Enum.empty?(invalid_values) ->
{:error,
field: :member_field_visibility,
message: "All values in member_field_visibility must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end end
else else
:ok :ok

View file

@ -7,7 +7,6 @@ defmodule Mv.Constants do
:first_name, :first_name,
:last_name, :last_name,
:email, :email,
:birth_date,
:paid, :paid,
:phone_number, :phone_number,
:join_date, :join_date,

View file

@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
attr :id, :string, doc: "the optional id of flash container" attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :kind, :atom,
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message" slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -56,16 +60,20 @@ defmodule MvWeb.CoreComponents do
id={@id} id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert" role="alert"
class="toast toast-top toast-end z-50" class="z-50 toast toast-top toast-end"
{@rest} {@rest}
> >
<div class={[ <div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap", "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info", @kind == :info && "alert-info",
@kind == :error && "alert-error" @kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
]}> ]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
<div> <div>
<p :if={@title} class="font-semibold">{@title}</p> <p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p> <p>{msg}</p>
@ -300,7 +308,7 @@ defmodule MvWeb.CoreComponents do
end) end)
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} /> <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label"> <span class="label">
@ -312,7 +320,11 @@ defmodule MvWeb.CoreComponents do
checked={@checked} checked={@checked}
class={@class || "checkbox checkbox-sm"} class={@class || "checkbox checkbox-sm"}
{@rest} {@rest}
/>{@label} />{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span> </span>
</label> </label>
<.error :for={msg <- @errors}>{msg}</.error> <.error :for={msg <- @errors}>{msg}</.error>
@ -322,9 +334,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "select"} = assigns) do def input(%{type: "select"} = assigns) do
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<span :if={@label} class="label mb-1">{@label}</span> <span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<select <select
id={@id} id={@id}
name={@name} name={@name}
@ -343,9 +361,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do def input(%{type: "textarea"} = assigns) do
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<span :if={@label} class="label mb-1">{@label}</span> <span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<textarea <textarea
id={@id} id={@id}
name={@name} name={@name}
@ -364,9 +388,15 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here... # All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do def input(assigns) do
~H""" ~H"""
<fieldset class="fieldset mb-2"> <fieldset class="mb-2 fieldset">
<label> <label>
<span :if={@label} class="label mb-1">{@label}</span> <span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<input <input
type={@type} type={@type}
name={@name} name={@name}
@ -637,7 +667,7 @@ defmodule MvWeb.CoreComponents do
<div class="mt-14"> <div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100"> <dl class="-my-4 divide-y divide-zinc-100">
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> <div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt> <dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
<dd class="text-zinc-700">{value}</dd> <dd class="text-zinc-700">{value}</dd>
</div> </div>
</dl> </dl>

View file

@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do def flash_group(assigns) do
~H""" ~H"""
<div id={@id} aria-live="polite"> <div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} /> <.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} /> <.flash kind={:error} flash={@flash} />

View file

@ -0,0 +1,146 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `: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 filter changes
"""
use MvWeb, :live_component
@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(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn btn-ghost gap-2",
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
</button>
</li>
</ul>
</div>
"""
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("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -14,7 +14,7 @@ defmodule MvWeb.MemberLive.Form do
- first_name, last_name, email - first_name, last_name, email
**Optional:** **Optional:**
- birth_date, phone_number, address fields (city, street, house_number, postal_code) - phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date - join_date, exit_date
- paid status - paid status
- notes - notes
@ -37,7 +37,7 @@ defmodule MvWeb.MemberLive.Form do
<.header> <.header>
{@page_title} {@page_title}
<:subtitle> <:subtitle>
{gettext("Use this form to manage member records and their properties.")} {gettext("Fields marked with an asterisk (*) cannot be empty.")}
</:subtitle> </:subtitle>
</.header> </.header>
@ -45,7 +45,6 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:first_name]} label={gettext("First Name")} required /> <.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required /> <.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
<.input field={@form[:phone_number]} label={gettext("Phone Number")} /> <.input field={@form[:phone_number]} label={gettext("Phone Number")} />
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />

View file

@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do
- `delete` - Remove a member from the database - `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection - `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members - `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
## Implementation Notes ## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery) - Search uses PostgreSQL full-text search (plainto_tsquery)
@ -47,7 +48,7 @@ defmodule MvWeb.MemberLive.Index do
Initializes the LiveView state. Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration, Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`. payment filter, and member selection. Actual data loading happens in `handle_params/3`.
""" """
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
@ -95,7 +96,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "") |> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, []) |> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible) |> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields) |> assign(:all_custom_fields, all_custom_fields)
@ -106,6 +108,7 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible, :member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection) FieldVisibility.get_visible_member_fields(initial_selection)
) )
|> assign(:member_fields_visible, get_visible_member_fields(settings))
# We call handle params to use the query from the URL # We call handle params to use the query from the URL
{:ok, socket} {:ok, socket}
@ -137,10 +140,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_event("select_member", %{"id" => id}, socket) do def handle_event("select_member", %{"id" => id}, socket) do
selected = selected =
if id in socket.assigns.selected_members do if MapSet.member?(socket.assigns.selected_members, id) do
List.delete(socket.assigns.selected_members, id) MapSet.delete(socket.assigns.selected_members, id)
else else
[id | socket.assigns.selected_members] MapSet.put(socket.assigns.selected_members, id)
end end
{:noreply, assign(socket, :selected_members, selected)} {:noreply, assign(socket, :selected_members, selected)}
@ -148,13 +151,11 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_event("select_all", _params, socket) do def handle_event("select_all", _params, socket) do
members = socket.assigns.members all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
all_ids = Enum.map(members, & &1.id)
selected = selected =
if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do if MapSet.equal?(socket.assigns.selected_members, all_ids) do
[] MapSet.new()
else else
all_ids all_ids
end end
@ -162,6 +163,52 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)} {:noreply, assign(socket, :selected_members, selected)}
end end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
# Filter members that are in the selection and have email addresses
formatted_emails =
socket.assigns.members
|> Enum.filter(fn member ->
MapSet.member?(selected_ids, member.id) && member.email && member.email != ""
end)
|> Enum.map(&format_member_email/1)
email_count = length(formatted_emails)
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
end
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Handle Infos from Child Components # Handle Infos from Child Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -194,22 +241,17 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_info({:search_changed, q}, socket) do def handle_info({:search_changed, q}, socket) do
# Update query assign first socket =
socket = assign(socket, :query, q) socket
|> assign(:query, q)
# Load members with the new query |> load_members()
socket = load_members(socket, q)
existing_field_query = socket.assigns.sort_field existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order existing_sort_query = socket.assigns.sort_order
# Build the URL with queries # Build the URL with queries
query_params = query_params =
build_query_params(socket, %{ build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
})
# Set the new path with params # Set the new path with params
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -222,6 +264,31 @@ defmodule MvWeb.MemberLive.Index do
)} )}
end end
@impl true
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:paid_filter, filter)
|> load_members()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true @impl true
def handle_info({:field_toggled, field_string, visible}, socket) do def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection # Update user field selection
@ -289,7 +356,7 @@ defmodule MvWeb.MemberLive.Index do
@doc """ @doc """
Handles URL parameter changes. Handles URL parameter changes.
Parses query parameters for search query, sort field, sort order, and field selection, Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
then loads members accordingly. This enables bookmarkable URLs and then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation. browser back/forward navigation.
""" """
@ -322,10 +389,12 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> maybe_update_search(params) |> maybe_update_search(params)
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection) |> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members(params["query"]) |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
{:noreply, socket} {:noreply, socket}
@ -423,10 +492,12 @@ defmodule MvWeb.MemberLive.Index do
end end
query_params = query_params =
build_query_params(socket, %{ build_query_params(
"sort_field" => field_str, socket.assigns.query,
"sort_order" => Atom.to_string(order) field_str,
}) Atom.to_string(order),
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -481,13 +552,45 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :user_field_selection, selection) assign(socket, :user_field_selection, selection)
end end
# Loads members from the database with custom field values and applies search/sort filters. # Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
else
sort_field
end
order_str =
if is_atom(sort_order) do
Atom.to_string(sort_order)
else
sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"sort_order" => order_str
}
# Only add paid_filter to URL if it's set
case paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
# #
# Process: # Process:
# 1. Builds base query with selected fields # 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level) # 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided # 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields) # 4. Applies payment status filter if set
# 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
# #
# Performance Considerations: # Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database # - Database-level filtering: Custom field values are filtered directly in the database
@ -499,7 +602,9 @@ defmodule MvWeb.MemberLive.Index do
# consider implementing pagination (see Issue #165). # consider implementing pagination (see Issue #165).
# #
# Returns the socket with `:members` assigned. # Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do defp load_members(socket) do
search_query = socket.assigns.query
query = query =
Mv.Membership.Member Mv.Membership.Member
|> Ash.Query.new() |> Ash.Query.new()
@ -512,6 +617,9 @@ defmodule MvWeb.MemberLive.Index do
# Apply the search filter first # Apply the search filter first
query = apply_search_filter(query, search_query) query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state # Apply sorting based on current socket state
# For custom fields, we sort after loading # For custom fields, we sort after loading
{query, sort_after_load} = {query, sort_after_load} =
@ -586,6 +694,24 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Applies payment status filter to the query.
#
# Filter values:
# - nil: No filter, return all members
# - :paid: Only members with paid == true
# - :not_paid: Members with paid == false or paid == nil (not paid)
defp apply_paid_filter(query, nil), do: query
defp apply_paid_filter(query, :paid) do
Ash.Query.filter(query, expr(paid == true))
end
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
end
# Functions to toggle sorting order # Functions to toggle sorting order
defp toggle_order(:asc), do: :desc defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
@ -876,6 +1002,29 @@ defmodule MvWeb.MemberLive.Index do
socket socket
end end
# Updates paid filter from URL parameters if present.
#
# Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
filter = determine_paid_filter(filter_str)
assign(socket, :paid_filter, filter)
end
defp maybe_update_paid_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :paid_filter, nil)
end
# Determines valid paid filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
# Ash's security recommendation to never pass untrusted input directly to filters.
defp determine_paid_filter("paid"), do: :paid
defp determine_paid_filter("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil
# ------------------------------------------------------------- # -------------------------------------------------------------
# Helper Functions for Custom Field Values # Helper Functions for Custom Field Values
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -908,11 +1057,28 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Gets the configuration for all member fields with their show_in_overview values. # Formats a member's email in the format "First Last <email>"
# Used for copy_emails feature to create email-client-friendly format.
defp format_member_email(member) do
first_name = member.first_name || ""
last_name = member.last_name || ""
name =
[first_name, last_name]
|> Enum.filter(&(&1 != ""))
|> Enum.join(" ")
if name == "" do
member.email
else
"#{name} <#{member.email}>"
end
end
# Gets the list of member fields that should be visible in the overview.
# #
# Reads the visibility configuration from Settings and returns a map with all member fields # Reads the visibility configuration from Settings and returns only the fields
# and their show_in_overview values (true or false). Fields not configured in settings # where show_in_overview is true. Fields not configured in settings default to true.
# default to true.
# #
# Performance: This function uses the already-loaded settings to avoid N+1 queries. # Performance: This function uses the already-loaded settings to avoid N+1 queries.
# Settings should be loaded once in mount/3 and passed to this function. # Settings should be loaded once in mount/3 and passed to this function.
@ -920,62 +1086,20 @@ defmodule MvWeb.MemberLive.Index do
# Parameters: # Parameters:
# - `settings` - The settings struct loaded from the database # - `settings` - The settings struct loaded from the database
# #
# Returns a map: %{field_name => show_in_overview} # Returns a list of atoms representing visible member field names.
#
# This can be used for:
# - Rendering the overview (filtering visible fields)
# - UI configuration dropdowns (showing all fields with their current state)
# - Dynamic field management
# #
# Fields are read from the global Constants module. # Fields are read from the global Constants module.
@spec get_member_field_configurations(map()) :: %{atom() => boolean()} @spec get_visible_member_fields(map()) :: [atom()]
defp get_member_field_configurations(settings) do defp get_visible_member_fields(settings) do
# Get all eligible fields from the global constants # Get all eligible fields from the global constants
all_fields = Mv.Constants.member_fields() all_fields = Mv.Constants.member_fields()
# Normalize visibility config (JSONB may return string keys) # JSONB stores keys as strings
visibility_config = normalize_visibility_config(settings.member_field_visibility || %{}) visibility_config = settings.member_field_visibility || %{}
Enum.reduce(all_fields, %{}, fn field, acc -> # Filter to only return visible fields
show_in_overview = Map.get(visibility_config, field, true) Enum.filter(all_fields, fn field ->
Map.put(acc, field, show_in_overview) Map.get(visibility_config, Atom.to_string(field), true)
end) end)
end end
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
# This is a local helper to avoid N+1 queries by reusing the normalization logic.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Extracts custom field IDs from visible custom field strings
# Format: "custom_field_<id>" -> <id>
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, "custom_field_") do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
end end

View file

@ -9,18 +9,44 @@
custom_fields={@all_custom_fields} custom_fields={@all_custom_fields}
selected_fields={@user_field_selection} selected_fields={@user_field_selection}
/> />
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
aria-label={gettext("Copy email addresses of selected members")}
>
<.icon name="hero-clipboard-document" />
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
</.button>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
href={"mailto:?bcc=#{@members |> Enum.filter(&(MapSet.member?(@selected_members, &1.id) && &1.email)) |> Enum.map(& &1.email) |> Enum.join(",")}"}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}> <.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")} <.icon name="hero-plus" /> {gettext("New Member")}
</.button> </.button>
</:actions> </:actions>
</.header> </.header>
<.live_component <div class="flex flex-wrap gap-4 items-center">
module={MvWeb.Components.SearchBarComponent} <.live_component
id="search-bar" module={MvWeb.Components.SearchBarComponent}
query={@query} id="search-bar"
placeholder={gettext("Search...")} query={@query}
/> placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
member_count={length(@members)}
/>
</div>
<.table <.table
id="members" id="members"
@ -40,7 +66,7 @@
type="checkbox" type="checkbox"
name="select_all" name="select_all"
phx-click="select_all" phx-click="select_all"
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()} checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")} aria-label={gettext("Select all members")}
role="checkbox" role="checkbox"
/> />
@ -52,7 +78,7 @@
name={member.id} name={member.id}
phx-click="select_member" phx-click="select_member"
phx-value-id={member.id} phx-value-id={member.id}
checked={member.id in @selected_members} checked={MapSet.member?(@selected_members, member.id)}
phx-capture-click phx-capture-click
phx-stop-propagation phx-stop-propagation
aria-label={gettext("Select member")} aria-label={gettext("Select member")}
@ -221,6 +247,14 @@
> >
{member.join_date} {member.join_date}
</:col> </:col>
<:col :let={member} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")
]}>
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span>
</:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -10,7 +10,7 @@ defmodule MvWeb.MemberLive.Show do
- Return to member list - Return to member list
## Displayed Information ## Displayed Information
- Basic: name, email, dates (birth, join, exit) - Basic: name, email, dates (join, exit)
- Contact: phone number - Contact: phone number
- Address: street, house number, postal code, city - Address: street, house number, postal code, city
- Status: paid flag - Status: paid flag
@ -48,7 +48,6 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("First Name")}>{@member.first_name}</:item> <:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item> <:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item> <:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}> <:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")} {if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item> </:item>

View file

@ -10,37 +10,37 @@ msgid ""
msgstr "" msgstr ""
"Language: en\n" "Language: en\n"
#: lib/mv_web/components/core_components.ex:356 #: lib/mv_web/components/core_components.ex:386
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "Bist du sicher?" msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex:80 #: lib/mv_web/components/layouts.ex:82
#: lib/mv_web/components/layouts.ex:92 #: lib/mv_web/components/layouts.ex:94
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt" msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "Löschen" msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -48,13 +48,13 @@ msgid "Edit"
msgstr "Bearbeite" msgstr "Bearbeite"
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117 #: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "Mitglied bearbeiten" msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -69,9 +69,9 @@ msgstr "E-Mail"
msgid "First Name" msgid "First Name"
msgstr "Vorname" msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "Beitrittsdatum" msgstr "Beitrittsdatum"
@ -82,78 +82,75 @@ msgstr "Beitrittsdatum"
msgid "Last Name" msgid "Last Name"
msgstr "Nachname" msgstr "Nachname"
#: lib/mv_web/live/member_live/index.html.heex:6 #: lib/mv_web/live/member_live/index.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Member" msgid "New Member"
msgstr "Neues Mitglied" msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "Anzeigen" msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex:87 #: lib/mv_web/components/layouts.ex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!" msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex:75 #: lib/mv_web/components/layouts.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden" msgstr "Keine Internetverbindung gefunden"
#: lib/mv_web/components/core_components.ex:74 #: lib/mv_web/components/core_components.ex:82
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "close" msgid "close"
msgstr "schließen" msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex:48 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/show.ex:51 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr "Geburtsdatum"
#: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date" msgid "Exit Date"
msgstr "Austrittsdatum" msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:61 #: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "Hausnummer" msgstr "Hausnummer"
#: lib/mv_web/live/member_live/form.ex:53 #: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:58 #: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "Notizen" msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/components/payment_filter_component.ex:94
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/components/payment_filter_component.ex:144
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "Telefonnummer" msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "Postleitzahl" msgstr "Postleitzahl"
#: lib/mv_web/live/member_live/form.ex:80 #: lib/mv_web/live/member_live/form.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save Member" msgid "Save Member"
msgstr "Mitglied speichern" msgstr "Mitglied speichern"
@ -161,36 +158,32 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:78
#: lib/mv_web/live/user_live/form.ex:248 #: lib/mv_web/live/user_live/form.ex:248
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
msgstr "Speichern..." msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "Straße" msgstr "Straße"
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
#: lib/mv_web/live/member_live/show.ex:47 #: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Id" msgid "Id"
msgstr "ID" msgstr "ID"
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
msgstr "Nein" msgstr "Nein"
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:115
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Show Member" msgid "Show Member"
msgstr "Mitglied anzeigen" msgstr "Mitglied anzeigen"
@ -200,22 +193,23 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "Ja" msgstr "Ja"
#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:137
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "erstellt" msgstr "erstellt"
#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "aktualisiert" msgstr "aktualisiert"
@ -225,7 +219,7 @@ msgstr "aktualisiert"
msgid "Incorrect email or password" msgid "Incorrect email or password"
msgstr "Falsche E-Mail oder Passwort" msgstr "Falsche E-Mail oder Passwort"
#: lib/mv_web/live/member_live/form.ex:145 #: lib/mv_web/live/member_live/form.ex:144
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "Mitglied %{action} erfolgreich" msgstr "Mitglied %{action} erfolgreich"
@ -258,7 +252,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:81
#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -311,7 +305,7 @@ msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:25 #: lib/mv_web/components/layouts/navbar.ex:25
#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.ex:73
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
@ -365,12 +359,12 @@ msgstr "Profil"
msgid "Required" msgid "Required"
msgstr "Erforderlich" msgstr "Erforderlich"
#: lib/mv_web/live/member_live/index.html.heex:37 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "Alle Mitglieder auswählen" msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:51 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "Mitglied auswählen" msgstr "Mitglied auswählen"
@ -515,7 +509,7 @@ msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen
msgid "Linked Member" msgid "Linked Member"
msgstr "Verknüpftes Mitglied" msgstr "Verknüpftes Mitglied"
#: lib/mv_web/live/member_live/show.ex:63 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked User" msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in" msgstr "Verknüpfte*r Benutzer*in"
@ -526,7 +520,7 @@ msgstr "Verknüpfte*r Benutzer*in"
msgid "No member linked" msgid "No member linked"
msgstr "Kein Mitglied verknüpft" msgstr "Kein Mitglied verknüpft"
#: lib/mv_web/live/member_live/show.ex:73 #: lib/mv_web/live/member_live/show.ex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No user linked" msgid "No user linked"
msgstr "Keine*r Benutzer*in verknüpft" msgstr "Keine*r Benutzer*in verknüpft"
@ -556,7 +550,7 @@ msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten" msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Suchen..." msgstr "Suchen..."
@ -572,7 +566,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort" msgid "Click to sort"
msgstr "Klicke um zu sortieren" msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:63 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
msgstr "Vorname" msgstr "Vorname"
@ -613,8 +607,8 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Choose a custom field" msgid "Choose a custom field"
msgstr "Wähle ein Benutzerdefiniertes Feld" msgstr "Wähle ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/member_live/form.ex:59 #: lib/mv_web/live/member_live/form.ex:58
#: lib/mv_web/live/member_live/show.ex:78 #: lib/mv_web/live/member_live/show.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Field Values" msgid "Custom Field Values"
msgstr "Benutzerdefinierte Feldwerte" msgstr "Benutzerdefinierte Feldwerte"
@ -782,7 +776,85 @@ msgstr "Mitglied entverknüpfen"
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "Entverknüpfung geplant" msgstr "Entverknüpfung geplant"
#~ #: lib/mv_web/live/custom_field_live/index.ex:97 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
#: lib/mv_web/live/member_live/index.html.heex:10
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
#: lib/mv_web/live/member_live/index.html.heex:13
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr "E-Mails kopieren"
#: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr "Keine E-Mail-Adressen gefunden"
#: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt"
#: lib/mv_web/live/member_live/index.html.heex:18
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
#: lib/mv_web/live/member_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen"
#: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format
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/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben."
#: lib/mv_web/components/core_components.ex:206
#: lib/mv_web/components/core_components.ex:223
#: lib/mv_web/components/core_components.ex:250
#: lib/mv_web/components/core_components.ex:277
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/live/components/payment_filter_component.ex:80
#: lib/mv_web/live/components/payment_filter_component.ex:143
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
#: lib/mv_web/live/components/payment_filter_component.ex:54
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex:108
#: lib/mv_web/live/components/payment_filter_component.ex:145
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr "Nicht bezahlt"
#: lib/mv_web/live/components/payment_filter_component.ex:65
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr "Zahlungsfilter"
#~ #: lib/mv_web/live/member_live/form.ex:48
#~ #: lib/mv_web/live/member_live/show.ex:51
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:" #~ msgid "Birth Date"
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" #~ msgstr "Geburtsdatum"

View file

@ -11,37 +11,37 @@
msgid "" msgid ""
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex:356 #: lib/mv_web/components/core_components.ex:386
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:80 #: lib/mv_web/components/layouts.ex:82
#: lib/mv_web/components/layouts.ex:92 #: lib/mv_web/components/layouts.ex:94
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -49,13 +49,13 @@ msgid "Edit"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117 #: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -70,9 +70,9 @@ msgstr ""
msgid "First Name" msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
@ -83,78 +83,75 @@ msgstr ""
msgid "Last Name" msgid "Last Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:6 #: lib/mv_web/live/member_live/index.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:87 #: lib/mv_web/components/layouts.ex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:75 #: lib/mv_web/components/layouts.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex:74 #: lib/mv_web/components/core_components.ex:82
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "close" msgid "close"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:48 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/show.ex:51 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Birth Date" msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:52 #: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57 #: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/components/payment_filter_component.ex:94
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/components/payment_filter_component.ex:144
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:80 #: lib/mv_web/live/member_live/form.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save Member" msgid "Save Member"
msgstr "" msgstr ""
@ -162,36 +159,32 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:78
#: lib/mv_web/live/user_live/form.ex:248 #: lib/mv_web/live/user_live/form.ex:248
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:47 #: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:115
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show Member" msgid "Show Member"
msgstr "" msgstr ""
@ -201,22 +194,23 @@ msgstr ""
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:137
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "" msgstr ""
@ -226,7 +220,7 @@ msgstr ""
msgid "Incorrect email or password" msgid "Incorrect email or password"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:145 #: lib/mv_web/live/member_live/form.ex:144
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "" msgstr ""
@ -259,7 +253,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:81
#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -312,7 +306,7 @@ msgid "Member"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:25 #: lib/mv_web/components/layouts/navbar.ex:25
#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.ex:73
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
@ -366,12 +360,12 @@ msgstr ""
msgid "Required" msgid "Required"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:37 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:51 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "" msgstr ""
@ -516,7 +510,7 @@ msgstr ""
msgid "Linked Member" msgid "Linked Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:63 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked User" msgid "Linked User"
msgstr "" msgstr ""
@ -527,7 +521,7 @@ msgstr ""
msgid "No member linked" msgid "No member linked"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:73 #: lib/mv_web/live/member_live/show.ex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No user linked" msgid "No user linked"
msgstr "" msgstr ""
@ -557,7 +551,7 @@ msgid "Toggle dark mode"
msgstr "" msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
@ -573,7 +567,7 @@ msgstr ""
msgid "Click to sort" msgid "Click to sort"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:63 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
msgstr "" msgstr ""
@ -614,8 +608,8 @@ msgstr ""
msgid "Choose a custom field" msgid "Choose a custom field"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:59 #: lib/mv_web/live/member_live/form.ex:58
#: lib/mv_web/live/member_live/show.ex:78 #: lib/mv_web/live/member_live/show.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Field Values" msgid "Custom Field Values"
msgstr "" msgstr ""
@ -782,3 +776,80 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/member_live/index.html.heex:10
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:13
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:18
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr ""
#: lib/mv_web/components/core_components.ex:206
#: lib/mv_web/components/core_components.ex:223
#: lib/mv_web/components/core_components.ex:250
#: lib/mv_web/components/core_components.ex:277
#, elixir-autogen, elixir-format
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:80
#: lib/mv_web/live/components/payment_filter_component.ex:143
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:54
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:108
#: lib/mv_web/live/components/payment_filter_component.ex:145
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:65
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""

View file

@ -11,37 +11,37 @@ msgstr ""
"Language: en\n" "Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex:356 #: lib/mv_web/components/core_components.ex:386
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:80 #: lib/mv_web/components/layouts.ex:82
#: lib/mv_web/components/layouts.ex:92 #: lib/mv_web/components/layouts.ex:94
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -49,13 +49,13 @@ msgid "Edit"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:117 #: lib/mv_web/live/member_live/show.ex:116
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -70,9 +70,9 @@ msgstr ""
msgid "First Name" msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
@ -83,78 +83,75 @@ msgstr ""
msgid "Last Name" msgid "Last Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:6 #: lib/mv_web/live/member_live/index.html.heex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:87 #: lib/mv_web/components/layouts.ex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex:75 #: lib/mv_web/components/layouts.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex:74 #: lib/mv_web/components/core_components.ex:82
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "close" msgid "close"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:48 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/show.ex:51 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Birth Date" msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:52 #: lib/mv_web/live/member_live/form.ex:52
#: lib/mv_web/live/member_live/show.ex:57 #: lib/mv_web/live/member_live/show.ex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:53
#: lib/mv_web/live/member_live/show.ex:58
#, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/components/payment_filter_component.ex:94
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/components/payment_filter_component.ex:144
#: lib/mv_web/live/member_live/form.ex:48
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:80 #: lib/mv_web/live/member_live/form.ex:79
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Save Member" msgid "Save Member"
msgstr "" msgstr ""
@ -162,36 +159,32 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/global_settings_live.ex:55
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:78
#: lib/mv_web/live/user_live/form.ex:248 #: lib/mv_web/live/user_live/form.ex:248
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Saving..." msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:47 #: lib/mv_web/live/member_live/show.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:115
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Show Member" msgid "Show Member"
msgstr "" msgstr ""
@ -201,22 +194,23 @@ msgstr ""
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:137
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "" msgstr ""
@ -226,7 +220,7 @@ msgstr ""
msgid "Incorrect email or password" msgid "Incorrect email or password"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:145 #: lib/mv_web/live/member_live/form.ex:144
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "" msgstr ""
@ -259,7 +253,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:81
#: lib/mv_web/live/user_live/form.ex:251 #: lib/mv_web/live/user_live/form.ex:251
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -312,7 +306,7 @@ msgid "Member"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:25 #: lib/mv_web/components/layouts/navbar.ex:25
#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.ex:73
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
@ -366,12 +360,12 @@ msgstr ""
msgid "Required" msgid "Required"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:37 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:51 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "" msgstr ""
@ -516,7 +510,7 @@ msgstr "User will be created without a password. Check 'Set Password' to add one
msgid "Linked Member" msgid "Linked Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:63 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked User" msgid "Linked User"
msgstr "" msgstr ""
@ -527,7 +521,7 @@ msgstr ""
msgid "No member linked" msgid "No member linked"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:73 #: lib/mv_web/live/member_live/show.ex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No user linked" msgid "No user linked"
msgstr "" msgstr ""
@ -557,7 +551,7 @@ msgid "Toggle dark mode"
msgstr "" msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
@ -573,7 +567,7 @@ msgstr ""
msgid "Click to sort" msgid "Click to sort"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:63 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "First name" msgid "First name"
msgstr "" msgstr ""
@ -614,8 +608,8 @@ msgstr ""
msgid "Choose a custom field" msgid "Choose a custom field"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:59 #: lib/mv_web/live/member_live/form.ex:58
#: lib/mv_web/live/member_live/show.ex:78 #: lib/mv_web/live/member_live/show.ex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom Field Values" msgid "Custom Field Values"
msgstr "" msgstr ""
@ -783,7 +777,85 @@ msgstr ""
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index.ex:97 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/member_live/index.html.heex:10
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:13
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:18
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:40
#, elixir-autogen, elixir-format
msgid "Fields marked with an asterisk (*) cannot be empty."
msgstr ""
#: lib/mv_web/components/core_components.ex:206
#: lib/mv_web/components/core_components.ex:223
#: lib/mv_web/components/core_components.ex:250
#: lib/mv_web/components/core_components.ex:277
#, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:80
#: lib/mv_web/live/components/payment_filter_component.ex:143
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:54
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:108
#: lib/mv_web/live/components/payment_filter_component.ex:145
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:65
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex:48
#~ #: lib/mv_web/live/member_live/show.ex:51
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:" #~ msgid "Birth Date"
#~ msgstr "" #~ msgstr ""

View file

@ -0,0 +1,69 @@
defmodule Mv.Repo.Migrations.RemoveBirthDateFromMembers do
@moduledoc """
Removes the birth_date column from the members table.
The birth_date field has been removed from the application because most users
don't record birthday data. Users who need this can use a custom field instead.
This migration also updates the search_vector trigger to remove birth_date.
"""
use Ecto.Migration
def up do
# Update the trigger function to remove birth_date from search_vector
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Remove the birth_date column
alter table(:members) do
remove :birth_date
end
end
def down do
# Add the birth_date column back
alter table(:members) do
add :birth_date, :date
end
# Restore the trigger function with birth_date
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
end
end

View file

@ -112,7 +112,6 @@ for member_attrs <- [
first_name: "Hans", first_name: "Hans",
last_name: "Müller", last_name: "Müller",
email: "hans.mueller@example.de", email: "hans.mueller@example.de",
birth_date: ~D[1985-06-15],
join_date: ~D[2023-01-15], join_date: ~D[2023-01-15],
paid: true, paid: true,
phone_number: "+49301234567", phone_number: "+49301234567",
@ -125,7 +124,6 @@ for member_attrs <- [
first_name: "Greta", first_name: "Greta",
last_name: "Schmidt", last_name: "Schmidt",
email: "greta.schmidt@example.de", email: "greta.schmidt@example.de",
birth_date: ~D[1990-03-22],
join_date: ~D[2023-02-01], join_date: ~D[2023-02-01],
paid: false, paid: false,
phone_number: "+49309876543", phone_number: "+49309876543",
@ -139,7 +137,6 @@ for member_attrs <- [
first_name: "Friedrich", first_name: "Friedrich",
last_name: "Wagner", last_name: "Wagner",
email: "friedrich.wagner@example.de", email: "friedrich.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10], join_date: ~D[2022-11-10],
paid: true, paid: true,
phone_number: "+49301122334", phone_number: "+49301122334",
@ -151,7 +148,6 @@ for member_attrs <- [
first_name: "Marianne", first_name: "Marianne",
last_name: "Wagner", last_name: "Wagner",
email: "marianne.wagner@example.de", email: "marianne.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10], join_date: ~D[2022-11-10],
paid: true, paid: true,
phone_number: "+49301122334", phone_number: "+49301122334",
@ -186,7 +182,6 @@ linked_members = [
first_name: "Maria", first_name: "Maria",
last_name: "Weber", last_name: "Weber",
email: "maria.weber@example.de", email: "maria.weber@example.de",
birth_date: ~D[1992-07-14],
join_date: ~D[2023-03-15], join_date: ~D[2023-03-15],
paid: true, paid: true,
phone_number: "+49301357924", phone_number: "+49301357924",
@ -202,7 +197,6 @@ linked_members = [
first_name: "Thomas", first_name: "Thomas",
last_name: "Klein", last_name: "Klein",
email: "thomas.klein@example.de", email: "thomas.klein@example.de",
birth_date: ~D[1988-12-03],
join_date: ~D[2023-04-01], join_date: ~D[2023-04-01],
paid: false, paid: false,
phone_number: "+49302468135", phone_number: "+49302468135",

View file

@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
@valid_attrs %{ @valid_attrs %{
first_name: "John", first_name: "John",
last_name: "Doe", last_name: "Doe",
birth_date: ~D[1990-01-01],
paid: true, paid: true,
email: "john@example.com", email: "john@example.com",
phone_number: "+49123456789", phone_number: "+49123456789",
@ -43,12 +42,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email" assert error_message(errors, :email) =~ "is not a valid email"
end end
test "Birth date is optional but must not be in the future" do
attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :birth_date) =~ "cannot be in the future"
end
test "Paid is optional but must be boolean if specified" do test "Paid is optional but must be boolean if specified" do
attrs = Map.put(@valid_attrs, :paid, nil) attrs = Map.put(@valid_attrs, :paid, nil)
attrs2 = Map.put(@valid_attrs, :paid, "yes") attrs2 = Map.put(@valid_attrs, :paid, "yes")

View file

@ -0,0 +1,183 @@
defmodule MvWeb.Components.PaymentFilterComponentTest do
@moduledoc """
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with no filter active (nil)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Should show "All" text and no badge
assert has_element?(view, "#payment-filter")
refute has_element?(view, "#payment-filter .badge")
end
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with not_paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
end
describe "dropdown behavior" do
test "dropdown opens on button click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially dropdown is closed
refute has_element?(view, "#payment-filter ul[role='menu']")
# Click to open
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Dropdown should be visible
assert has_element?(view, "#payment-filter ul[role='menu']")
end
test "dropdown closes after selecting an option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
assert has_element?(view, "#payment-filter ul[role='menu']")
# Select an option - this should close the dropdown
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# After selection, dropdown should be closed
# Note: The dropdown closes via assign, which is reflected in the next render
refute has_element?(view, "#payment-filter ul[role='menu']")
end
end
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "All" option
view
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
assert_patch(view)
end
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
end
end
describe "accessibility" do
test "has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Main button should have aria-haspopup and aria-expanded
assert html =~ ~s(aria-haspopup="true")
assert html =~ ~s(aria-expanded="false")
assert html =~ ~s(aria-label=)
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Check aria-expanded is now true
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
# Options should have role="menuitemradio"
assert html =~ ~s(role="menuitemradio")
end
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# "Paid" option should have aria-checked="true"
# Check both possible orderings of attributes
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
end
end
end

View file

@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
- Custom field values are correctly formatted for different types - Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-" - Members without custom field values show empty cell or "-"
""" """
use MvWeb.ConnCase, async: true # async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query

View file

@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
{:ok, _} = {:ok, _} =
Mv.Membership.update_settings(settings, %{ Mv.Membership.update_settings(settings, %{
member_field_visibility: Map.new(fields_to_hide, &{&1, false}) member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
}) })
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)

View file

@ -249,4 +249,441 @@ defmodule MvWeb.MemberLive.IndexTest do
# Verify the member was actually deleted from the database # Verify the member was actually deleted from the database
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end end
describe "copy_emails feature" do
setup do
# Create test members
{:ok, member1} =
Mv.Membership.create_member(%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
})
{:ok, member2} =
Mv.Membership.create_member(%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
})
{:ok, member3} =
Mv.Membership.create_member(%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
})
%{member1: member1, member2: member2, member3: member3}
end
test "copy_emails event formats selected members correctly", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
view
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails event with no selection shows error flash", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Trigger copy_emails event directly (button not visible when no selection)
# This tests the edge case where event is triggered without selection
result = render_hook(view, "copy_emails", %{})
# Should show error flash
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
end
test "copy_emails event with all members selected formats all emails", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select all members via select_all
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
end
test "copy_emails handles members with special characters in names", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select member with umlauts
view
|> element("[phx-click='select_member'][phx-value-id='#{member3.id}']")
|> render_click()
# Trigger copy_emails event - should not crash
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows success
assert render(view) =~ "1"
end
test "copy_emails handles case where selected member is deleted before copy", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Delete the member from the database
Ash.destroy!(member1)
# Trigger copy_emails event directly - selection still contains the deleted ID
# but the member is no longer in @members list after reload
result = render_hook(view, "copy_emails", %{})
# Should show error since no visible members match selection
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
end
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
view
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|> render_click()
# Get the socket state to verify the formatted email string
state = :sys.get_state(view.pid)
selected_members = state.socket.assigns.selected_members
# Verify MapSet is used
assert %MapSet{} = selected_members
assert MapSet.size(selected_members) == 2
end
test "email format is 'First Last <email>' with comma separator", %{
conn: conn,
member1: _member1
} do
# Test the format_member_email function indirectly
# by checking the push_event payload structure
conn = conn_with_oidc_user(conn)
# Create a member with known data
{:ok, test_member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
})
{:ok, view, _html} = live(conn, "/members")
# Select the test member
view
|> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']")
|> render_click()
# The format should be "Test Format <test.format@example.com>"
# We verify this by checking the flash shows 1 email was copied
view |> element("#copy-emails-btn") |> render_click()
assert render(view) =~ "1"
end
test "copy button is not visible when no members are selected", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Ensure no members are selected (default state)
refute has_element?(view, "#copy-emails-btn")
end
test "copy button is visible when members are selected", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Button should now be visible
assert has_element?(view, "#copy-emails-btn")
end
test "copy button click triggers event and shows flash", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Click copy button
view |> element("#copy-emails-btn") |> render_click()
# Flash message should appear
assert has_element?(view, "#flash-group")
end
end
describe "payment filter integration" do
setup do
# Create members with different payment status
# Use unique names that won't appear elsewhere in the HTML
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Zahler",
last_name: "Mitglied",
email: "zahler@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
})
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
})
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
end
test "filter shows all members when no filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter shows only paid members when paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
end
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
refute html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter combines with search query (AND)", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
assert html =~ paid_member.first_name
end
test "filter combines with sorting", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Click on email sort header
view
|> element("[data-testid='email']")
|> render_click()
# Filter should be preserved in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "sort_field=email"
end
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "URL parameter is correctly read on page load", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
# Only paid member should be visible
assert html =~ paid_member.first_name
# Filter badge should be visible
assert html =~ "badge"
end
test "invalid URL parameter is ignored", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
# All members should be visible (filter not applied)
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
end
test "search maintains filter state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{"query" => "test"})
# Filter state should be maintained in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
end
describe "paid column in table" do
setup do
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Paid",
last_name: "Member",
email: "paid.column@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Unpaid",
last_name: "Member",
email: "unpaid.column@example.com",
paid: false
})
%{paid_member: paid_member, unpaid_member: unpaid_member}
end
test "paid column shows green badge for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for success badge (green)
assert html =~ "badge-success"
end
test "paid column shows red badge for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for error badge (red)
assert html =~ "badge-error"
end
test "paid column shows 'Yes' for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "Yes" text inside badge
assert html =~ "badge-success"
assert html =~ "Yes"
end
test "paid column shows 'No' for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "No" text inside badge
assert html =~ "badge-error"
assert html =~ "No"
end
end
end end