Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
f0613fe1e5
29 changed files with 1661 additions and 405 deletions
|
|
@ -53,6 +53,8 @@ steps:
|
|||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- mix credo
|
||||
# Check that translations are up to date
|
||||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:17.6
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
|
||||
- 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
|
||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
||||
- Relationship data extraction from Ash manage_relationship during validation
|
||||
- Copy button count now shows only visible selected members when filtering
|
||||
|
||||
|
|
|
|||
1
Justfile
1
Justfile
|
|
@ -29,6 +29,7 @@ lint:
|
|||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
audit:
|
||||
mix sobelow --config
|
||||
|
|
|
|||
|
|
@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
|
|||
// Hooks for LiveView components
|
||||
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
|
||||
Hooks.ComboBox = {
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ Member (1) → (N) Properties
|
|||
### Member Constraints
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Birth date cannot be in future
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
|
|
@ -169,7 +168,7 @@ Member (1) → (N) Properties
|
|||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **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
|
||||
|
||||
### Usage Example
|
||||
|
|
@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with:
|
|||
- tokens (jti, purpose, extra_data)
|
||||
|
||||
**Personal Data (GDPR):**
|
||||
- All member fields (name, email, birth_date, address)
|
||||
- All member fields (name, email, address)
|
||||
- User email
|
||||
- Token subject
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ Table members {
|
|||
first_name text [not null, note: 'Member first 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)']
|
||||
birth_date date [null, note: 'Date of birth (cannot be in future)']
|
||||
paid boolean [null, note: 'Payment status flag']
|
||||
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)']
|
||||
|
|
@ -153,7 +152,7 @@ Table members {
|
|||
**Club Member Master Data**
|
||||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, birth date, email)
|
||||
- Personal information (name, email)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
|
@ -183,7 +182,6 @@ Table members {
|
|||
**Validation Rules:**
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- birth_date: cannot be in future
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
### Feature Summary
|
||||
|
|
@ -1559,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
|
|||
|
||||
---
|
||||
|
||||
**Document Version:** 1.2
|
||||
**Last Updated:** 2025-11-27
|
||||
**Document Version:** 1.3
|
||||
**Last Updated:** 2025-12-02
|
||||
**Maintainer:** Development Team
|
||||
**Status:** Living Document (update as project evolves)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
- ✅ Sorting by basic fields
|
||||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#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:**
|
||||
- [#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)
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
|
||||
|
||||
**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]
|
||||
- [#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)
|
||||
|
||||
**Missing Features:**
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do
|
|||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Phone number format: international format with 6-20 digits
|
||||
- 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
|
||||
|
||||
## Full-Text Search
|
||||
|
|
@ -284,11 +284,6 @@ defmodule Mv.Membership.Member do
|
|||
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
|
||||
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:join_date)],
|
||||
|
|
@ -351,10 +346,6 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 5, max_length: 254
|
||||
end
|
||||
|
||||
attribute :birth_date, :date do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :paid, :boolean do
|
||||
allow_nil? true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ defmodule Mv.Membership do
|
|||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `visibility_config` - A map of member field names (atoms) to boolean visibility values
|
||||
(e.g., `%{street: false, house_number: false}`)
|
||||
- `visibility_config` - A map of member field names (strings) to boolean visibility values
|
||||
(e.g., `%{"street" => false, "house_number" => false}`)
|
||||
|
||||
## Returns
|
||||
|
||||
|
|
@ -145,9 +145,9 @@ defmodule Mv.Membership do
|
|||
## Examples
|
||||
|
||||
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
|
||||
%{street: false, house_number: false}
|
||||
%{"street" => false, "house_number" => false}
|
||||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ defmodule Mv.Membership.Setting do
|
|||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `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
|
||||
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"})
|
||||
|
||||
# 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,
|
||||
domain: Mv.Membership,
|
||||
|
|
@ -67,43 +67,6 @@ defmodule Mv.Membership.Setting do
|
|||
description "Updates the visibility configuration for member fields in the overview"
|
||||
require_atomic? false
|
||||
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
|
||||
|
||||
|
|
@ -111,23 +74,39 @@ defmodule Mv.Membership.Setting do
|
|||
validate present(:club_name), on: [:create, :update]
|
||||
validate string_length(:club_name, min: 1), on: [:create, :update]
|
||||
|
||||
# Validate that member_field_visibility map contains only boolean values
|
||||
# This allows dynamic fields without hardcoding specific field names
|
||||
# Validate member_field_visibility map structure and content
|
||||
validate fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
invalid_entries =
|
||||
# Validate all values are booleans
|
||||
invalid_values =
|
||||
Enum.filter(visibility, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
if Enum.empty?(invalid_entries) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "All values in member_field_visibility must be booleans"}
|
||||
# Validate all keys are valid member fields
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(visibility, fn {key, _value} ->
|
||||
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
|
||||
else
|
||||
:ok
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ defmodule Mv.Constants do
|
|||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
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"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
|
@ -56,16 +60,20 @@ defmodule MvWeb.CoreComponents do
|
|||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="toast toast-top toast-end z-50"
|
||||
class="z-50 toast toast-top toast-end"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@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 == :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>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
|
|
@ -300,7 +308,7 @@ defmodule MvWeb.CoreComponents do
|
|||
end)
|
||||
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<span class="label">
|
||||
|
|
@ -312,7 +320,11 @@ defmodule MvWeb.CoreComponents do
|
|||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
/>{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
|
|
@ -322,9 +334,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<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
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -343,9 +361,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<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
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -364,9 +388,15 @@ defmodule MvWeb.CoreComponents do
|
|||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<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
|
||||
type={@type}
|
||||
name={@name}
|
||||
|
|
@ -637,7 +667,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<div class="mt-14">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
def flash_group(assigns) do
|
||||
~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={:error} flash={@flash} />
|
||||
|
||||
|
|
|
|||
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
146
lib/mv_web/live/components/payment_filter_component.ex
Normal 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
|
||||
|
|
@ -14,7 +14,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
- first_name, last_name, email
|
||||
|
||||
**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
|
||||
- paid status
|
||||
- notes
|
||||
|
|
@ -37,7 +37,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage member records and their properties.")}
|
||||
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
|
|
@ -45,7 +45,6 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.input field={@form[:first_name]} label={gettext("First 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[:birth_date]} label={gettext("Birth Date")} type="date" />
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `delete` - Remove a member from the database
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||
|
||||
## Implementation Notes
|
||||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||||
|
|
@ -47,7 +48,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
Initializes the LiveView state.
|
||||
|
||||
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
|
||||
def mount(_params, session, socket) do
|
||||
|
|
@ -95,7 +96,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:paid_filter, nil)
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:all_custom_fields, all_custom_fields)
|
||||
|
|
@ -106,6 +108,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
:member_fields_visible,
|
||||
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
|
||||
{:ok, socket}
|
||||
|
|
@ -137,10 +140,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
@impl true
|
||||
def handle_event("select_member", %{"id" => id}, socket) do
|
||||
selected =
|
||||
if id in socket.assigns.selected_members do
|
||||
List.delete(socket.assigns.selected_members, id)
|
||||
if MapSet.member?(socket.assigns.selected_members, id) do
|
||||
MapSet.delete(socket.assigns.selected_members, id)
|
||||
else
|
||||
[id | socket.assigns.selected_members]
|
||||
MapSet.put(socket.assigns.selected_members, id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
|
|
@ -148,13 +151,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("select_all", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
|
||||
all_ids = Enum.map(members, & &1.id)
|
||||
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
|
||||
|
||||
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
|
||||
all_ids
|
||||
end
|
||||
|
|
@ -162,6 +163,52 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
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
|
||||
# -----------------------------------------------------------------
|
||||
|
|
@ -194,22 +241,17 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_info({:search_changed, q}, socket) do
|
||||
# Update query assign first
|
||||
socket = assign(socket, :query, q)
|
||||
|
||||
# Load members with the new query
|
||||
socket = load_members(socket, q)
|
||||
socket =
|
||||
socket
|
||||
|> assign(:query, q)
|
||||
|> load_members()
|
||||
|
||||
existing_field_query = socket.assigns.sort_field
|
||||
existing_sort_query = socket.assigns.sort_order
|
||||
|
||||
# Build the URL with queries
|
||||
query_params =
|
||||
build_query_params(socket, %{
|
||||
"query" => q,
|
||||
"sort_field" => existing_field_query,
|
||||
"sort_order" => existing_sort_query
|
||||
})
|
||||
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
|
||||
|
||||
# Set the new path with params
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -222,6 +264,31 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)}
|
||||
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
|
||||
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||||
# Update user field selection
|
||||
|
|
@ -289,7 +356,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
@doc """
|
||||
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
|
||||
browser back/forward navigation.
|
||||
"""
|
||||
|
|
@ -322,10 +389,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_paid_filter(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|> load_members(params["query"])
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -423,10 +492,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
query_params =
|
||||
build_query_params(socket, %{
|
||||
"sort_field" => field_str,
|
||||
"sort_order" => Atom.to_string(order)
|
||||
})
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
field_str,
|
||||
Atom.to_string(order),
|
||||
socket.assigns.paid_filter
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
|
|
@ -481,13 +552,45 @@ defmodule MvWeb.MemberLive.Index do
|
|||
assign(socket, :user_field_selection, selection)
|
||||
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:
|
||||
# 1. Builds base query with selected fields
|
||||
# 2. Loads custom field values for visible custom fields (filtered at database level)
|
||||
# 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:
|
||||
# - 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).
|
||||
#
|
||||
# Returns the socket with `:members` assigned.
|
||||
defp load_members(socket, search_query) do
|
||||
defp load_members(socket) do
|
||||
search_query = socket.assigns.query
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
|
|
@ -512,6 +617,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Apply the search filter first
|
||||
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
|
||||
# For custom fields, we sort after loading
|
||||
{query, sort_after_load} =
|
||||
|
|
@ -586,6 +694,24 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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
|
||||
defp toggle_order(:asc), do: :desc
|
||||
defp toggle_order(:desc), do: :asc
|
||||
|
|
@ -876,6 +1002,29 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
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
|
||||
# -------------------------------------------------------------
|
||||
|
|
@ -908,11 +1057,28 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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
|
||||
# and their show_in_overview values (true or false). Fields not configured in settings
|
||||
# default to true.
|
||||
# Reads the visibility configuration from Settings and returns only the fields
|
||||
# where show_in_overview is true. Fields not configured in settings default to true.
|
||||
#
|
||||
# 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.
|
||||
|
|
@ -920,62 +1086,20 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Parameters:
|
||||
# - `settings` - The settings struct loaded from the database
|
||||
#
|
||||
# Returns a map: %{field_name => show_in_overview}
|
||||
#
|
||||
# This can be used for:
|
||||
# - Rendering the overview (filtering visible fields)
|
||||
# - UI configuration dropdowns (showing all fields with their current state)
|
||||
# - Dynamic field management
|
||||
# Returns a list of atoms representing visible member field names.
|
||||
#
|
||||
# Fields are read from the global Constants module.
|
||||
@spec get_member_field_configurations(map()) :: %{atom() => boolean()}
|
||||
defp get_member_field_configurations(settings) do
|
||||
@spec get_visible_member_fields(map()) :: [atom()]
|
||||
defp get_visible_member_fields(settings) do
|
||||
# Get all eligible fields from the global constants
|
||||
all_fields = Mv.Constants.member_fields()
|
||||
|
||||
# Normalize visibility config (JSONB may return string keys)
|
||||
visibility_config = normalize_visibility_config(settings.member_field_visibility || %{})
|
||||
# JSONB stores keys as strings
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
show_in_overview = Map.get(visibility_config, field, true)
|
||||
Map.put(acc, field, show_in_overview)
|
||||
# Filter to only return visible fields
|
||||
Enum.filter(all_fields, fn field ->
|
||||
Map.get(visibility_config, Atom.to_string(field), true)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,18 +9,44 @@
|
|||
custom_fields={@all_custom_fields}
|
||||
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"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.PaymentFilterComponent}
|
||||
id="payment-filter"
|
||||
paid_filter={@paid_filter}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="members"
|
||||
|
|
@ -40,7 +66,7 @@
|
|||
type="checkbox"
|
||||
name="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")}
|
||||
role="checkbox"
|
||||
/>
|
||||
|
|
@ -52,7 +78,7 @@
|
|||
name={member.id}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
checked={member.id in @selected_members}
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
phx-capture-click
|
||||
phx-stop-propagation
|
||||
aria-label={gettext("Select member")}
|
||||
|
|
@ -221,6 +247,14 @@
|
|||
>
|
||||
{member.join_date}
|
||||
</: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}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
- Return to member list
|
||||
|
||||
## Displayed Information
|
||||
- Basic: name, email, dates (birth, join, exit)
|
||||
- Basic: name, email, dates (join, exit)
|
||||
- Contact: phone number
|
||||
- Address: street, house number, postal code, city
|
||||
- Status: paid flag
|
||||
|
|
@ -48,7 +48,6 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
||||
<:item title={gettext("Email")}>{@member.email}</:item>
|
||||
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
|
||||
<:item title={gettext("Paid")}>
|
||||
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
|
||||
</:item>
|
||||
|
|
|
|||
|
|
@ -10,37 +10,37 @@ msgid ""
|
|||
msgstr ""
|
||||
"Language: en\n"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:356
|
||||
#: lib/mv_web/components/core_components.ex:386
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
msgstr "Bist du sicher?"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:80
|
||||
#: lib/mv_web/components/layouts.ex:92
|
||||
#: lib/mv_web/components/layouts.ex:82
|
||||
#: lib/mv_web/components/layouts.ex:94
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr "Verbindung wird wiederhergestellt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#: lib/mv_web/live/member_live/form.ex:53
|
||||
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||
#: lib/mv_web/live/member_live/show.ex:58
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
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/index.html.heex:66
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -48,13 +48,13 @@ msgid "Edit"
|
|||
msgstr "Bearbeite"
|
||||
|
||||
#: 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
|
||||
msgid "Edit Member"
|
||||
msgstr "Mitglied bearbeiten"
|
||||
|
||||
#: 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/user_live/form.ex:46
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
|
|
@ -69,9 +69,9 @@ msgstr "E-Mail"
|
|||
msgid "First Name"
|
||||
msgstr "Vorname"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:215
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr "Beitrittsdatum"
|
||||
|
|
@ -82,78 +82,75 @@ msgstr "Beitrittsdatum"
|
|||
msgid "Last Name"
|
||||
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
|
||||
msgid "New Member"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
msgstr "Anzeigen"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:87
|
||||
#: lib/mv_web/components/layouts.ex:89
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr "Etwas ist schiefgelaufen!"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:75
|
||||
#: lib/mv_web/components/layouts.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr "Keine Internetverbindung gefunden"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:74
|
||||
#: lib/mv_web/components/core_components.ex:82
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr "schließen"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:48
|
||||
#: lib/mv_web/live/member_live/show.ex:51
|
||||
#, 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
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit Date"
|
||||
msgstr "Austrittsdatum"
|
||||
|
||||
#: 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
|
||||
#: 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 "Hausnummer"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:58
|
||||
#: lib/mv_web/live/member_live/form.ex:52
|
||||
#: lib/mv_web/live/member_live/show.ex:57
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Notes"
|
||||
msgstr "Notizen"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:49
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex:94
|
||||
#: 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
|
||||
msgid "Paid"
|
||||
msgstr "Bezahlt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#: lib/mv_web/live/member_live/form.ex:49
|
||||
#: lib/mv_web/live/member_live/index.html.heex:197
|
||||
#: lib/mv_web/live/member_live/show.ex:54
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr "Telefonnummer"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:57
|
||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#: lib/mv_web/live/member_live/form.ex:56
|
||||
#: lib/mv_web/live/member_live/index.html.heex:161
|
||||
#: lib/mv_web/live/member_live/show.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr "Postleitzahl"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:80
|
||||
#: lib/mv_web/live/member_live/form.ex:79
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Member"
|
||||
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_value_live/form.ex:74
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
msgstr "Speichern..."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:55
|
||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
||||
#: lib/mv_web/live/member_live/show.ex:60
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:125
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "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/show.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No"
|
||||
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
|
||||
msgid "Show Member"
|
||||
msgstr "Mitglied anzeigen"
|
||||
|
|
@ -200,22 +193,23 @@ msgstr "Mitglied anzeigen"
|
|||
msgid "This is a member record from your database."
|
||||
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/show.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
#: 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/member_live/form.ex:138
|
||||
#: lib/mv_web/live/member_live/form.ex:137
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr "erstellt"
|
||||
|
||||
#: 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/member_live/form.ex:139
|
||||
#: lib/mv_web/live/member_live/form.ex:138
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "update"
|
||||
msgstr "aktualisiert"
|
||||
|
|
@ -225,7 +219,7 @@ msgstr "aktualisiert"
|
|||
msgid "Incorrect email or password"
|
||||
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
|
||||
msgid "Member %{action} successfully"
|
||||
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/index.ex:120
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
|
|
@ -311,7 +305,7 @@ msgid "Member"
|
|||
msgstr "Mitglied"
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -365,12 +359,12 @@ msgstr "Profil"
|
|||
msgid "Required"
|
||||
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
|
||||
msgid "Select all members"
|
||||
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
|
||||
msgid "Select member"
|
||||
msgstr "Mitglied auswählen"
|
||||
|
|
@ -515,7 +509,7 @@ msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen
|
|||
msgid "Linked Member"
|
||||
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
|
||||
msgid "Linked User"
|
||||
msgstr "Verknüpfte*r Benutzer*in"
|
||||
|
|
@ -526,7 +520,7 @@ msgstr "Verknüpfte*r Benutzer*in"
|
|||
msgid "No member linked"
|
||||
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
|
||||
msgid "No user linked"
|
||||
msgstr "Keine*r Benutzer*in verknüpft"
|
||||
|
|
@ -556,7 +550,7 @@ msgid "Toggle dark mode"
|
|||
msgstr "Dunklen Modus umschalten"
|
||||
|
||||
#: 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
|
||||
msgid "Search..."
|
||||
msgstr "Suchen..."
|
||||
|
|
@ -572,7 +566,7 @@ msgstr "Benutzer*innen"
|
|||
msgid "Click to sort"
|
||||
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
|
||||
msgid "First name"
|
||||
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"
|
||||
msgstr "Wähle ein Benutzerdefiniertes Feld"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:59
|
||||
#: lib/mv_web/live/member_live/show.ex:78
|
||||
#: lib/mv_web/live/member_live/form.ex:58
|
||||
#: lib/mv_web/live/member_live/show.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Field Values"
|
||||
msgstr "Benutzerdefinierte Feldwerte"
|
||||
|
|
@ -782,7 +776,85 @@ msgstr "Mitglied entverknüpfen"
|
|||
msgid "Unlinking scheduled"
|
||||
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
|
||||
#~ msgid "To confirm deletion, please enter the custom field slug:"
|
||||
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:"
|
||||
#~ msgid "Birth Date"
|
||||
#~ msgstr "Geburtsdatum"
|
||||
|
|
|
|||
|
|
@ -11,37 +11,37 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:356
|
||||
#: lib/mv_web/components/core_components.ex:386
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:80
|
||||
#: lib/mv_web/components/layouts.ex:92
|
||||
#: lib/mv_web/components/layouts.ex:82
|
||||
#: lib/mv_web/components/layouts.ex:94
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#: lib/mv_web/live/member_live/form.ex:53
|
||||
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||
#: lib/mv_web/live/member_live/show.ex:58
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
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/index.html.heex:66
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -49,13 +49,13 @@ msgid "Edit"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "Edit Member"
|
||||
msgstr ""
|
||||
|
||||
#: 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/user_live/form.ex:46
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
|
|
@ -70,9 +70,9 @@ msgstr ""
|
|||
msgid "First Name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:215
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr ""
|
||||
|
|
@ -83,78 +83,75 @@ msgstr ""
|
|||
msgid "Last Name"
|
||||
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
|
||||
msgid "New Member"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:87
|
||||
#: lib/mv_web/components/layouts.ex:89
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:75
|
||||
#: lib/mv_web/components/layouts.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:74
|
||||
#: lib/mv_web/components/core_components.ex:82
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:48
|
||||
#: lib/mv_web/live/member_live/show.ex:51
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#, 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 ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:52
|
||||
#: lib/mv_web/live/member_live/show.ex:57
|
||||
#, 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"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:49
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex:94
|
||||
#: 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
|
||||
msgid "Paid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#: lib/mv_web/live/member_live/form.ex:49
|
||||
#: lib/mv_web/live/member_live/index.html.heex:197
|
||||
#: lib/mv_web/live/member_live/show.ex:54
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:57
|
||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#: lib/mv_web/live/member_live/form.ex:56
|
||||
#: lib/mv_web/live/member_live/index.html.heex:161
|
||||
#: lib/mv_web/live/member_live/show.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:80
|
||||
#: lib/mv_web/live/member_live/form.ex:79
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Member"
|
||||
msgstr ""
|
||||
|
|
@ -162,36 +159,32 @@ msgstr ""
|
|||
#: 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/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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:55
|
||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
||||
#: lib/mv_web/live/member_live/show.ex:60
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:125
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Id"
|
||||
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/show.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:116
|
||||
#: lib/mv_web/live/member_live/show.ex:115
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show Member"
|
||||
msgstr ""
|
||||
|
|
@ -201,22 +194,23 @@ msgstr ""
|
|||
msgid "This is a member record from your database."
|
||||
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/show.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: 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/member_live/form.ex:138
|
||||
#: lib/mv_web/live/member_live/form.ex:137
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr ""
|
||||
|
||||
#: 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/member_live/form.ex:139
|
||||
#: lib/mv_web/live/member_live/form.ex:138
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "update"
|
||||
msgstr ""
|
||||
|
|
@ -226,7 +220,7 @@ msgstr ""
|
|||
msgid "Incorrect email or password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:145
|
||||
#: lib/mv_web/live/member_live/form.ex:144
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -259,7 +253,7 @@ msgstr ""
|
|||
#: 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_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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
|
|
@ -312,7 +306,7 @@ msgid "Member"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -366,12 +360,12 @@ msgstr ""
|
|||
msgid "Required"
|
||||
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
|
||||
msgid "Select all members"
|
||||
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
|
||||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
|
@ -516,7 +510,7 @@ msgstr ""
|
|||
msgid "Linked Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:63
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked User"
|
||||
msgstr ""
|
||||
|
|
@ -527,7 +521,7 @@ msgstr ""
|
|||
msgid "No member linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:73
|
||||
#: lib/mv_web/live/member_live/show.ex:72
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No user linked"
|
||||
msgstr ""
|
||||
|
|
@ -557,7 +551,7 @@ msgid "Toggle dark mode"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "Search..."
|
||||
msgstr ""
|
||||
|
|
@ -573,7 +567,7 @@ msgstr ""
|
|||
msgid "Click to sort"
|
||||
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
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
|
@ -614,8 +608,8 @@ msgstr ""
|
|||
msgid "Choose a custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:59
|
||||
#: lib/mv_web/live/member_live/show.ex:78
|
||||
#: lib/mv_web/live/member_live/form.ex:58
|
||||
#: lib/mv_web/live/member_live/show.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Field Values"
|
||||
msgstr ""
|
||||
|
|
@ -782,3 +776,80 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Unlinking scheduled"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -11,37 +11,37 @@ msgstr ""
|
|||
"Language: en\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
|
||||
msgid "Actions"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:80
|
||||
#: lib/mv_web/components/layouts.ex:92
|
||||
#: lib/mv_web/components/layouts.ex:82
|
||||
#: lib/mv_web/components/layouts.ex:94
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#: lib/mv_web/live/member_live/form.ex:53
|
||||
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||
#: lib/mv_web/live/member_live/show.ex:58
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
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/index.html.heex:66
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -49,13 +49,13 @@ msgid "Edit"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "Edit Member"
|
||||
msgstr ""
|
||||
|
||||
#: 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/user_live/form.ex:46
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
|
|
@ -70,9 +70,9 @@ msgstr ""
|
|||
msgid "First Name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:215
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr ""
|
||||
|
|
@ -83,78 +83,75 @@ msgstr ""
|
|||
msgid "Last Name"
|
||||
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
|
||||
msgid "New Member"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:87
|
||||
#: lib/mv_web/components/layouts.ex:89
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:75
|
||||
#: lib/mv_web/components/layouts.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:74
|
||||
#: lib/mv_web/components/core_components.ex:82
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:48
|
||||
#: lib/mv_web/live/member_live/show.ex:51
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#, 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 ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:52
|
||||
#: lib/mv_web/live/member_live/show.ex:57
|
||||
#, 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"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:49
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex:94
|
||||
#: 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
|
||||
msgid "Paid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#: lib/mv_web/live/member_live/form.ex:49
|
||||
#: lib/mv_web/live/member_live/index.html.heex:197
|
||||
#: lib/mv_web/live/member_live/show.ex:54
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:57
|
||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#: lib/mv_web/live/member_live/form.ex:56
|
||||
#: lib/mv_web/live/member_live/index.html.heex:161
|
||||
#: lib/mv_web/live/member_live/show.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:80
|
||||
#: lib/mv_web/live/member_live/form.ex:79
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Save Member"
|
||||
msgstr ""
|
||||
|
|
@ -162,36 +159,32 @@ msgstr ""
|
|||
#: 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/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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:55
|
||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
||||
#: lib/mv_web/live/member_live/show.ex:60
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:125
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Id"
|
||||
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/show.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:116
|
||||
#: lib/mv_web/live/member_live/show.ex:115
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Show Member"
|
||||
msgstr ""
|
||||
|
|
@ -201,22 +194,23 @@ msgstr ""
|
|||
msgid "This is a member record from your database."
|
||||
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/show.ex:53
|
||||
#: lib/mv_web/live/member_live/show.ex:52
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: 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/member_live/form.ex:138
|
||||
#: lib/mv_web/live/member_live/form.ex:137
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "create"
|
||||
msgstr ""
|
||||
|
||||
#: 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/member_live/form.ex:139
|
||||
#: lib/mv_web/live/member_live/form.ex:138
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "update"
|
||||
msgstr ""
|
||||
|
|
@ -226,7 +220,7 @@ msgstr ""
|
|||
msgid "Incorrect email or password"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:145
|
||||
#: lib/mv_web/live/member_live/form.ex:144
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -259,7 +253,7 @@ msgstr ""
|
|||
#: 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_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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
|
|
@ -312,7 +306,7 @@ msgid "Member"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -366,12 +360,12 @@ msgstr ""
|
|||
msgid "Required"
|
||||
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
|
||||
msgid "Select all members"
|
||||
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
|
||||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
|
@ -516,7 +510,7 @@ msgstr "User will be created without a password. Check 'Set Password' to add one
|
|||
msgid "Linked Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:63
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked User"
|
||||
msgstr ""
|
||||
|
|
@ -527,7 +521,7 @@ msgstr ""
|
|||
msgid "No member linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:73
|
||||
#: lib/mv_web/live/member_live/show.ex:72
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No user linked"
|
||||
msgstr ""
|
||||
|
|
@ -557,7 +551,7 @@ msgid "Toggle dark mode"
|
|||
msgstr ""
|
||||
|
||||
#: 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
|
||||
msgid "Search..."
|
||||
msgstr ""
|
||||
|
|
@ -573,7 +567,7 @@ msgstr ""
|
|||
msgid "Click to sort"
|
||||
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
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
|
@ -614,8 +608,8 @@ msgstr ""
|
|||
msgid "Choose a custom field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:59
|
||||
#: lib/mv_web/live/member_live/show.ex:78
|
||||
#: lib/mv_web/live/member_live/form.ex:58
|
||||
#: lib/mv_web/live/member_live/show.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Field Values"
|
||||
msgstr ""
|
||||
|
|
@ -783,7 +777,85 @@ msgstr ""
|
|||
msgid "Unlinking scheduled"
|
||||
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
|
||||
#~ msgid "To confirm deletion, please enter the custom field slug:"
|
||||
#~ msgid "Birth Date"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -112,7 +112,6 @@ for member_attrs <- [
|
|||
first_name: "Hans",
|
||||
last_name: "Müller",
|
||||
email: "hans.mueller@example.de",
|
||||
birth_date: ~D[1985-06-15],
|
||||
join_date: ~D[2023-01-15],
|
||||
paid: true,
|
||||
phone_number: "+49301234567",
|
||||
|
|
@ -125,7 +124,6 @@ for member_attrs <- [
|
|||
first_name: "Greta",
|
||||
last_name: "Schmidt",
|
||||
email: "greta.schmidt@example.de",
|
||||
birth_date: ~D[1990-03-22],
|
||||
join_date: ~D[2023-02-01],
|
||||
paid: false,
|
||||
phone_number: "+49309876543",
|
||||
|
|
@ -139,7 +137,6 @@ for member_attrs <- [
|
|||
first_name: "Friedrich",
|
||||
last_name: "Wagner",
|
||||
email: "friedrich.wagner@example.de",
|
||||
birth_date: ~D[1978-11-08],
|
||||
join_date: ~D[2022-11-10],
|
||||
paid: true,
|
||||
phone_number: "+49301122334",
|
||||
|
|
@ -151,7 +148,6 @@ for member_attrs <- [
|
|||
first_name: "Marianne",
|
||||
last_name: "Wagner",
|
||||
email: "marianne.wagner@example.de",
|
||||
birth_date: ~D[1978-11-08],
|
||||
join_date: ~D[2022-11-10],
|
||||
paid: true,
|
||||
phone_number: "+49301122334",
|
||||
|
|
@ -186,7 +182,6 @@ linked_members = [
|
|||
first_name: "Maria",
|
||||
last_name: "Weber",
|
||||
email: "maria.weber@example.de",
|
||||
birth_date: ~D[1992-07-14],
|
||||
join_date: ~D[2023-03-15],
|
||||
paid: true,
|
||||
phone_number: "+49301357924",
|
||||
|
|
@ -202,7 +197,6 @@ linked_members = [
|
|||
first_name: "Thomas",
|
||||
last_name: "Klein",
|
||||
email: "thomas.klein@example.de",
|
||||
birth_date: ~D[1988-12-03],
|
||||
join_date: ~D[2023-04-01],
|
||||
paid: false,
|
||||
phone_number: "+49302468135",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
birth_date: ~D[1990-01-01],
|
||||
paid: true,
|
||||
email: "john@example.com",
|
||||
phone_number: "+49123456789",
|
||||
|
|
@ -43,12 +42,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert error_message(errors, :email) =~ "is not a valid email"
|
||||
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
|
||||
attrs = Map.put(@valid_attrs, :paid, nil)
|
||||
attrs2 = Map.put(@valid_attrs, :paid, "yes")
|
||||
|
|
|
|||
183
test/mv_web/components/payment_filter_component_test.exs
Normal file
183
test/mv_web/components/payment_filter_component_test.exs
Normal 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
|
||||
|
|
@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
- Custom field values are correctly formatted for different types
|
||||
- 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
|
||||
require Ash.Query
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
|||
|
||||
{:ok, _} =
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -249,4 +249,441 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
# Verify the member was actually deleted from the database
|
||||
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue