Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
78ee92af73 Update renovate/renovate Docker tag to v43
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-15 00:04:23 +00:00
44 changed files with 165 additions and 3466 deletions

View file

@ -273,7 +273,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:43.19 image: renovate/renovate:43.15
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

View file

@ -58,12 +58,6 @@ config :mv,
max_rows: 1000 max_rows: 1000
] ]
# PDF Export configuration
config :mv,
pdf_export: [
row_limit: 5000
]
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production. # OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
config :mv, :oidc_role_sync, config :mv, :oidc_role_sync,
admin_group_name: nil, admin_group_name: nil,

View file

@ -85,7 +85,6 @@
- Many-to-many relationship with groups - Many-to-many relationship with groups
- Groups management UI (`/groups`) - Groups management UI (`/groups`)
- Filter and sort by groups in member list - Filter and sort by groups in member list
- Per-group filter in member list: one row per group with All / Yes / No (All/Alle); URL params `group_<uuid>=in|not_in`
- Groups displayed in member overview and detail views - Groups displayed in member overview and detail views
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27) - ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
- Member field import - Member field import

View file

@ -1,71 +0,0 @@
# PDF Generation: Imprintor statt Chromium
## Übersicht
Für die PDF-Generierung in der Mitgliederverwaltung verwenden wir **Imprintor** (`~> 0.5.0`) anstelle von Chromium-basierten Lösungen (wie z.B. Puppeteer, Chrome Headless, oder ähnliche).
## Warum Imprintor statt Chromium?
### 1. Ressourceneffizienz
- **Geringerer Speicherverbrauch**: Imprintor benötigt keine vollständige Browser-Instanz im Speicher
- **Niedrigere CPU-Last**: Native PDF-Generierung ohne Browser-Rendering-Pipeline
- **Kleinere Docker-Images**: Keine Chromium-Installation erforderlich (spart mehrere hundert MB)
### 2. Performance
- **Schnellere Generierung**: Direkte PDF-Generierung ohne HTML-Rendering-Overhead
- **Bessere Skalierbarkeit**: Kann mehrere PDFs parallel generieren ohne Browser-Instanzen zu verwalten
- **Niedrigere Latenz**: Keine Browser-Startup-Zeit
### 3. Deployment & Wartung
- **Einfacheres Deployment**: Keine System-Abhängigkeiten (Chromium, ChromeDriver, etc.)
- **Weniger Wartungsaufwand**: Keine Browser-Version-Updates zu verwalten
- **Bessere Container-Kompatibilität**: Funktioniert in minimalen Docker-Images (z.B. Alpine)
### 4. Sicherheit
- **Kleinere Angriffsfläche**: Keine Browser-Engine mit bekannten Sicherheitslücken
- **Isolation**: Weniger System-Calls und externe Prozesse
### 5. Elixir-Native Lösung
- **Erlang/OTP-Integration**: Nutzt die Vorteile der BEAM-VM (Concurrency, Fault Tolerance)
- **Type-Safety**: Bessere Integration mit Elixir-Typen und Pattern Matching
- **Einfachere Fehlerbehandlung**: Elixir-native Error-Handling statt externer Prozesse
## Wann Chromium trotzdem sinnvoll wäre
Chromium-basierte Lösungen sind sinnvoll, wenn:
- Komplexe JavaScript-Ausführung im HTML nötig ist
- Moderne CSS-Features (Grid, Flexbox, etc.) kritisch sind
- Screenshots von Web-Seiten generiert werden sollen
- Dynamische Inhalte gerendert werden müssen, die JavaScript erfordern
## Verwendung in diesem Projekt
Imprintor wird für folgende Anwendungsfälle verwendet:
- **Member-Export als PDF**: Generierung von Mitgliederlisten und -reports
- **Statische Reports**: PDF-Generierung für vordefinierte Report-Formate
- **Dokumente**: Generierung von Mitgliedschaftsbescheinigungen, Rechnungen, etc.
## Technische Details
- **Dependency**: `{:imprintor, "~> 0.5.0"}`
- **Typ**: Native Elixir-Bibliothek (vermutlich basierend auf Rust-NIFs oder ähnlichen Technologien)
- **Format**: Generiert PDF direkt aus HTML/Templates ohne Browser-Engine
## Migration von Chromium (falls vorhanden)
Falls zuvor eine Chromium-basierte Lösung verwendet wurde:
1. HTML-Templates müssen ggf. angepasst werden (kein JavaScript-Support)
2. CSS muss statisch sein (keine dynamischen Styles)
3. Komplexe Layouts sollten vorher getestet werden
## Weitere Ressourcen
- [Imprintor auf Hex.pm](https://hex.pm/packages/imprintor)
- [GitHub Repository](https://github.com/[imprintor-repo]) (falls verfügbar)

View file

@ -116,30 +116,4 @@ defmodule Mv.Config do
defp parse_and_validate_integer(_value, default) do defp parse_and_validate_integer(_value, default) do
default default
end end
@doc """
Returns the maximum number of rows allowed in PDF exports.
Reads the `row_limit` value from the PDF export configuration.
## Returns
- Maximum number of rows (default: 5000)
## Examples
iex> Mv.Config.pdf_export_row_limit()
5000
"""
@spec pdf_export_row_limit() :: pos_integer()
def pdf_export_row_limit do
get_pdf_export_config(:row_limit, 5000)
end
# Helper function to get PDF export config values
defp get_pdf_export_config(key, default) do
Application.get_env(:mv, :pdf_export, [])
|> Keyword.get(key, default)
|> parse_and_validate_integer(default)
end
end end

View file

@ -1,433 +0,0 @@
defmodule Mv.Membership.MemberExport.Build do
@moduledoc """
Builds export data structure for member exports (CSV/PDF).
Extracts common logic for loading, filtering, sorting, and formatting member data
into a unified structure that can be used by both CSV and PDF exporters.
Returns a structure:
```
%{
columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}],
rows: [[cell_string, ...]],
meta: %{generated_at: String.t(), member_count: integer(), ...}
}
```
No translations/Gettext in this module - labels come from the web layer via a function.
"""
require Ash.Query
import Ash.Expr
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@custom_field_prefix Mv.Constants.custom_field_prefix()
@doc """
Builds export data structure from parsed parameters.
- `actor` - Ash actor (e.g. current user)
- `parsed` - Map with export parameters (from `MemberExport.parse_params/1`)
- `label_fn` - Function to get labels for columns: `(key) -> String.t()`
Returns `{:ok, data}` or `{:error, :forbidden}`.
The `data` map contains:
- `columns`: List of column specs with `key`, `kind`, and optional `custom_field`
- `rows`: List of rows, each row is a list of cell strings
- `meta`: Metadata including `generated_at` and `member_count`
"""
@spec build(struct(), map(), (term() -> String.t())) ::
{:ok, map()} | {:error, :forbidden}
def build(actor, parsed, label_fn) when is_function(label_fn, 1) do
# Ensure sort custom field is loaded if needed
parsed = ensure_sort_custom_field_loaded(parsed)
custom_field_ids_union =
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
columns = build_columns(parsed, custom_fields_by_id, label_fn)
rows = build_rows(members, columns, custom_fields_by_id)
meta = build_meta(members)
{:ok, %{columns: columns, rows: rows, meta: meta}}
end
end
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
case extract_sort_custom_field_id(sort_field) do
nil -> parsed
id -> %{parsed | custom_field_ids: Enum.uniq([id | ids])}
end
end
defp extract_sort_custom_field_id(field) when is_binary(field) do
if String.starts_with?(field, @custom_field_prefix) do
String.trim_leading(field, @custom_field_prefix)
else
nil
end
end
defp extract_sort_custom_field_id(_), do: nil
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
query =
CustomField
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|> Ash.Query.select([:id, :name, :value_type])
case Ash.read(query, actor: actor) do
{:ok, custom_fields} ->
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
{:ok, by_id}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
find_and_add_custom_field(acc, id, custom_fields)
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp load_members(actor, parsed, custom_fields_by_id) do
{query, sort_after_load} = build_members_query(parsed, custom_fields_by_id)
case Ash.read(query, actor: actor) do
{:ok, members} ->
processed_members =
process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load)
{:ok, processed_members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_members_query(parsed, _custom_fields_by_id) do
select_fields =
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
need_cycles =
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
query =
if parsed.selected_ids != [] do
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
apply_search(query, parsed.query)
end
# Apply sorting at query level if possible (not custom fields)
maybe_sort(query, parsed.sort_field, parsed.sort_order)
end
defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do
members
|> apply_post_load_filters(parsed, custom_fields_by_id)
|> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load)
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
end
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
else
members
end
end
defp apply_post_load_sorting(members, parsed, custom_fields_by_id, sort_after_load) do
# Sort after load for custom fields (always, even with selected_ids)
if sort_after_load do
sort_members_by_custom_field(
members,
parsed.sort_field,
parsed.sort_order,
Map.values(custom_fields_by_id)
)
else
# For selected_ids, we may need to apply sorting that wasn't done at query level
if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do
# Re-sort in memory to ensure consistent ordering
sort_members_in_memory(members, parsed.sort_field, parsed.sort_order)
else
members
end
end
end
defp sort_members_in_memory(members, field, order) when is_binary(field) do
field_atom = String.to_existing_atom(field)
if field_atom in Mv.Constants.member_fields() do
sort_by_field(members, field_atom, order)
else
members
end
rescue
ArgumentError -> members
end
defp sort_members_in_memory(members, _field, _order), do: members
defp sort_by_field(members, field_atom, order) do
key_fn = fn member -> Map.get(member, field_atom) end
compare_fn = build_compare_fn(order)
Enum.sort_by(members, key_fn, compare_fn)
end
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
defp build_compare_fn(_), do: fn _a, _b -> true end
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
cfv_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
Ash.Query.load(query, custom_field_values: cfv_query)
end
defp apply_search(query, nil), do: query
defp apply_search(query, ""), do: query
defp apply_search(query, q) when is_binary(q) do
if String.trim(q) != "" do
Member.fuzzy_search(query, %{query: q})
else
query
end
end
defp maybe_sort(query, nil, _order), do: {query, false}
defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
end
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
key_fn = fn member ->
cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
end
members
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|> Enum.map(fn {m, _} -> m end)
end
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
(Map.get(cfv, :custom_field) &&
to_string(cfv.custom_field.id) == to_string(custom_field.id))
end)
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
end
defp apply_cycle_status_filter(members, _status, _show_current), do: members
defp add_computed_fields(members, computed_fields, show_current_cycle) do
computed_fields = computed_fields || []
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
# Format as string for export (controller will handle translation)
status_string = format_membership_fee_status(status)
Map.put(member, :membership_fee_status, status_string)
end)
else
members
end
end
defp format_membership_fee_status(:paid), do: "paid"
defp format_membership_fee_status(:unpaid), do: "unpaid"
defp format_membership_fee_status(:suspended), do: "suspended"
defp format_membership_fee_status(nil), do: ""
defp build_columns(parsed, custom_fields_by_id, label_fn) do
member_cols =
Enum.map(parsed.selectable_member_fields, fn field ->
%{
key: field,
kind: :member_field,
label: label_fn.(field)
}
end)
computed_cols =
Enum.map(parsed.computed_fields, fn key ->
atom_key = String.to_existing_atom(key)
%{
key: atom_key,
kind: :computed,
label: label_fn.(atom_key)
}
end)
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
if cf do
%{
key: to_string(id),
kind: :custom_field,
label: cf.name,
custom_field: cf
}
else
nil
end
end)
|> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols
end
defp build_rows(members, columns, custom_fields_by_id) do
Enum.map(members, fn member ->
Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end)
end)
end
defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) do
key_atom = key_to_atom(key)
value = Map.get(member, key_atom)
format_member_value(value)
end
defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}, _custom_fields_by_id) do
cfv = get_cfv_by_id(member, id)
if cfv do
CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf)
else
""
end
end
defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do
value = Map.get(member, key)
if is_binary(value), do: value, else: ""
end
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
try do
String.to_existing_atom(k)
rescue
ArgumentError -> k
end
end
defp get_cfv_by_id(member, id) do
values =
case Map.get(member, :custom_field_values) do
v when is_list(v) -> v
_ -> []
end
id_str = to_string(id)
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == id_str or
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
end)
end
defp format_member_value(nil), do: ""
defp format_member_value(true), do: "true"
defp format_member_value(false), do: "false"
defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp build_meta(members) do
%{
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
member_count: length(members)
}
end
end

View file

@ -1,456 +0,0 @@
defmodule Mv.Membership.MembersPDF do
@moduledoc """
Exports members to PDF using Typst templates and Imprintor.
Uses the same data structure as `MemberExport.Build` and converts it
to the format expected by the Typst template. Handles internationalization
for PDF-specific labels (title, metadata) and membership fee status.
Ensures deterministic output by maintaining column and row order.
Creates a temporary directory per request and copies the template there
to avoid symlink issues and ensure isolation.
"""
require Logger
use Gettext, backend: MvWeb.Gettext
alias Mv.Config
@template_filename "members_export.typ"
@doc """
Renders export data to PDF binary.
- `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
- `opts` - Keyword list with `:locale` (default: "en") and `:club_name` (default: "Club")
Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
The PDF binary starts with "%PDF" (PDF magic bytes).
Validates row count against configured limit before processing.
"""
@spec render(map(), keyword()) :: {:ok, binary()} | {:error, term()}
def render(export_data, opts \\ []) do
row_count = length(export_data.rows)
max_rows = Config.pdf_export_row_limit()
if row_count > max_rows do
Logger.warning(
"PDF export rejected: row count exceeds limit (rows: #{row_count}, max: #{max_rows})",
error_type: :row_limit_exceeded
)
{:error, {:row_limit_exceeded, row_count, max_rows}}
else
Logger.info(
"Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})"
)
locale = Keyword.get(opts, :locale, "en")
club_name = Keyword.get(opts, :club_name, "Club")
create_and_use_temp_directory(export_data, locale, club_name)
end
end
defp create_and_use_temp_directory(export_data, locale, club_name) do
case create_temp_directory() do
{:ok, temp_dir} ->
try do
with {:ok, template_content} <- load_template(),
{:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
{:ok, template_data} <-
convert_to_template_format(export_data, locale, club_name),
{:ok, config} <-
build_imprintor_config(template_content, template_data, temp_dir),
{:ok, pdf_binary} <- compile_to_pdf(config) do
Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})")
{:ok, pdf_binary}
else
{:error, reason} = error ->
Logger.error("PDF export failed: #{inspect(reason)}",
error_type: :pdf_export_failed
)
error
end
after
cleanup_temp_directory(temp_dir)
end
{:error, reason} = error ->
Logger.error("Failed to create temp directory: #{inspect(reason)}",
error_type: :temp_dir_creation_failed
)
error
end
end
defp create_temp_directory do
# Create unique temp directory per request
temp_base = System.tmp_dir!()
temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}")
case File.mkdir_p(temp_dir) do
:ok -> {:ok, temp_dir}
{:error, reason} -> {:error, {:temp_dir_creation_failed, reason}}
end
end
defp load_template do
# Try multiple paths: compiled app path and source path (for tests/dev)
template_paths = [
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"),
Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename])
]
Enum.reduce_while(template_paths, nil, fn path, _acc ->
case File.read(path) do
{:ok, content} -> {:halt, {:ok, content}}
{:error, _reason} -> {:cont, nil}
end
end)
|> case do
{:ok, content} -> {:ok, content}
nil -> {:error, {:template_not_found, :enoent}}
end
end
defp copy_template_to_temp(temp_dir, template_content) do
# Write template to temp directory (no symlinks, actual file copy)
template_path = Path.join(temp_dir, @template_filename)
case File.write(template_path, template_content) do
:ok -> {:ok, template_path}
{:error, reason} -> {:error, {:template_copy_failed, reason}}
end
end
defp cleanup_temp_directory(temp_dir) do
# Clean up temp directory and all contents
case File.rm_rf(temp_dir) do
{:ok, _} ->
:ok
{:error, reason, _} ->
Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}")
end
end
defp convert_to_template_format(export_data, locale, club_name) do
# Set locale for translations
Gettext.put_locale(MvWeb.Gettext, locale)
headers = Enum.map(export_data.columns, & &1.label)
column_count = length(export_data.columns)
meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
generated_at_raw =
Map.get(meta, :generated_at) ||
Map.get(meta, "generated_at") ||
DateTime.utc_now() |> DateTime.to_iso8601()
generated_at = format_datetime(generated_at_raw, locale)
member_count =
Map.get(meta, :member_count) ||
Map.get(meta, "member_count") ||
length(export_data.rows)
# Translate membership fee status and format dates in rows
rows =
export_data.rows
|> translate_membership_fee_status_in_rows(export_data.columns)
|> format_dates_in_rows(export_data.columns, locale)
# Build title based on locale
title = build_title(locale, club_name)
# Build translated labels for metadata
created_at_label = gettext("Created at:")
member_count_label = gettext("Member count:")
template_data = %{
"title" => title,
"created_at_label" => created_at_label,
"member_count_label" => member_count_label,
"generated_at" => generated_at,
"column_count" => column_count,
"headers" => headers,
"rows" => rows,
"columns" =>
Enum.map(export_data.columns, fn col ->
%{
"key" => to_string(col.key),
"kind" => to_string(col.kind),
"label" => col.label
}
end),
"meta" => %{
"generated_at" => generated_at,
"member_count" => member_count
},
"locale" => locale
}
{:ok, template_data}
end
defp build_title(_locale, club_name) do
gettext("Member %{club_name}", club_name: club_name)
end
defp format_datetime(iso8601_string, locale) when is_binary(iso8601_string) do
# Try to parse as DateTime first
case DateTime.from_iso8601(iso8601_string) do
{:ok, datetime, _offset} ->
format_datetime(datetime, locale)
{:ok, datetime} ->
format_datetime(datetime, locale)
{:error, _} ->
# Try NaiveDateTime if DateTime parsing fails
case NaiveDateTime.from_iso8601(iso8601_string) do
{:ok, naive_dt} ->
# Convert to DateTime in UTC
datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
format_datetime(datetime, locale)
{:error, _} ->
# If both fail, return original string
iso8601_string
end
end
end
defp format_datetime(%DateTime{} = datetime, locale) do
# Format as readable date and time (locale-specific)
case locale do
"de" ->
# German format: dd.mm.yyyy - HH:MM Uhr
Calendar.strftime(datetime, "%d.%m.%Y - %H:%M Uhr")
_ ->
# English format: MM/DD/YYYY HH:MM AM/PM
Calendar.strftime(datetime, "%m/%d/%Y %I:%M %p")
end
end
defp format_datetime(_, _), do: ""
defp format_date(%Date{} = date, locale) do
# Format as readable date (locale-specific)
case locale do
"de" ->
# German format: dd.mm.yyyy
Calendar.strftime(date, "%d.%m.%Y")
_ ->
# English format: MM/DD/YYYY
Calendar.strftime(date, "%m/%d/%Y")
end
end
defp format_date(_, _), do: ""
defp format_dates_in_rows(rows, columns, locale) do
date_indices = find_date_column_indices(columns)
if date_indices == [] do
rows
else
format_rows_dates(rows, date_indices, locale)
end
end
defp find_date_column_indices(columns) do
columns
|> Enum.with_index()
|> Enum.filter(fn {col, _idx} -> date_column?(col) end)
|> Enum.map(fn {_col, idx} -> idx end)
end
defp format_rows_dates(rows, date_indices, locale) do
Enum.map(rows, fn row -> format_row_dates(row, date_indices, locale) end)
end
defp format_row_dates(row, date_indices, locale) do
Enum.with_index(row)
|> Enum.map(fn {cell_value, idx} ->
if idx in date_indices do
format_cell_date(cell_value, locale)
else
cell_value
end
end)
end
defp date_column?(%{kind: :member_field, key: key}) do
key_atom = key_to_atom_safe(key)
key_atom in [:join_date, :exit_date, :membership_fee_start_date]
end
defp date_column?(_), do: false
defp key_to_atom_safe(key) when is_binary(key) do
try do
String.to_existing_atom(key)
rescue
ArgumentError -> key
end
end
defp key_to_atom_safe(key), do: key
defp format_cell_date(cell_value, locale) when is_binary(cell_value) do
format_cell_date_iso8601(cell_value, locale)
end
defp format_cell_date(cell_value, _locale), do: cell_value
defp format_cell_date_iso8601(cell_value, locale) do
case Date.from_iso8601(cell_value) do
{:ok, date} -> format_date(date, locale)
_ -> format_cell_date_datetime(cell_value, locale)
end
end
defp format_cell_date_datetime(cell_value, locale) do
case DateTime.from_iso8601(cell_value) do
{:ok, datetime} -> format_datetime(datetime, locale)
_ -> format_cell_date_naive(cell_value, locale)
end
end
defp format_cell_date_naive(cell_value, locale) do
case NaiveDateTime.from_iso8601(cell_value) do
{:ok, naive_dt} ->
datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
format_datetime(datetime, locale)
_ ->
cell_value
end
end
defp translate_membership_fee_status_in_rows(rows, columns) do
status_col_index = find_membership_fee_status_index(columns)
if status_col_index do
translate_rows_status(rows, status_col_index)
else
rows
end
end
defp find_membership_fee_status_index(columns) do
Enum.find_index(columns, fn col ->
col.kind == :computed && col.key == :membership_fee_status
end)
end
defp translate_rows_status(rows, status_col_index) do
Enum.map(rows, fn row ->
List.update_at(row, status_col_index, &translate_membership_fee_status/1)
end)
end
defp translate_membership_fee_status("paid"), do: gettext("Paid")
defp translate_membership_fee_status("unpaid"), do: gettext("Unpaid")
defp translate_membership_fee_status("suspended"), do: gettext("Suspended")
defp translate_membership_fee_status(value), do: value
defp build_imprintor_config(template_content, template_data, root_directory) do
# Imprintor.Config.new(source_document, inputs, options)
# inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
# options: set root_directory to temp dir to ensure no symlink issues
# extra_fonts: list of font file paths for Typst to use
extra_fonts = get_extra_fonts()
options = [root_directory: root_directory, extra_fonts: extra_fonts]
config = Imprintor.Config.new(template_content, template_data, options)
{:ok, config}
end
defp get_extra_fonts do
font_paths = get_font_paths()
Enum.reduce_while(font_paths, [], &find_fonts_in_path/2)
|> normalize_fonts_result()
end
defp get_font_paths do
[
Path.join(Application.app_dir(:mv, "priv"), "fonts"),
Path.join([File.cwd!(), "priv", "fonts"])
]
end
defp find_fonts_in_path(base_path, _acc) do
case File.ls(base_path) do
{:ok, files} -> process_font_files(files, base_path)
{:error, _reason} -> {:cont, []}
end
end
defp process_font_files(files, base_path) do
fonts =
files
|> Enum.filter(&String.ends_with?(&1, ".ttf"))
|> Enum.map(&Path.join(base_path, &1))
|> Enum.sort()
if fonts != [] do
{:halt, fonts}
else
{:cont, []}
end
end
defp normalize_fonts_result([]), do: []
defp normalize_fonts_result(fonts), do: fonts
defp compile_to_pdf(config) do
case Imprintor.compile_to_pdf(config) do
{:ok, pdf_binary} when is_binary(pdf_binary) ->
# Verify PDF magic bytes
if String.starts_with?(pdf_binary, "%PDF") do
{:ok, pdf_binary}
else
Logger.error(
"PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})"
)
{:error, :invalid_pdf_format}
end
{:error, reason} ->
Logger.error("PDF compilation failed",
error: inspect(reason),
error_type: :imprintor_compile_error
)
{:error, {:compile_error, reason}}
other ->
Logger.error("PDF compilation returned unexpected result: #{inspect(other)}",
error_type: :unexpected_result
)
{:error, {:unexpected_result, other}}
end
rescue
e ->
Logger.error("PDF compilation raised exception: #{inspect(e)}",
error_type: :compile_exception
)
{:error, {:compile_exception, e}}
end
end

View file

@ -151,17 +151,9 @@ defmodule MvWeb.CoreComponents do
## Examples ## Examples
<.dropdown_menu items={@items} open={@open} phx_target={@myself} /> <.dropdown_menu items={@items} open={@open} phx_target={@myself} />
When using custom content (e.g., forms), use the inner_block slot:
<.dropdown_menu button_label="Export" icon="hero-arrow-down-tray" open={@open} phx_target={@myself}>
<li role="none">
<form>...</form>
</li>
</.dropdown_menu>
""" """
attr :id, :string, default: "dropdown-menu" attr :id, :string, default: "dropdown-menu"
attr :items, :list, default: [], doc: "List of %{label: string, value: any} maps" attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
attr :button_label, :string, default: "Dropdown" attr :button_label, :string, default: "Dropdown"
attr :icon, :string, default: nil attr :icon, :string, default: nil
attr :checkboxes, :boolean, default: false attr :checkboxes, :boolean, default: false
@ -169,30 +161,8 @@ defmodule MvWeb.CoreComponents do
attr :open, :boolean, default: false, doc: "Whether the dropdown is open" attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
attr :button_class, :string,
default: nil,
doc: "Additional CSS classes for the button (e.g., btn-secondary)"
attr :menu_align, :string,
default: "right",
doc: "Menu alignment: 'left' or 'right' (default: right)"
attr :testid, :string, default: "dropdown-menu", doc: "data-testid for the dropdown container"
attr :button_testid, :string, default: "dropdown-button", doc: "data-testid for the button"
attr :menu_testid, :string,
default: nil,
doc: "data-testid for the menu (defaults to testid + '-menu')"
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
def dropdown_menu(assigns) do def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
assigns = assign(assigns, :menu_testid, menu_testid)
~H""" ~H"""
<div <div
class="relative" class="relative"
@ -200,7 +170,7 @@ defmodule MvWeb.CoreComponents do
phx-target={@phx_target} phx-target={@phx_target}
phx-window-keydown="close_dropdown" phx-window-keydown="close_dropdown"
phx-key="Escape" phx-key="Escape"
data-testid={@testid} data-testid="dropdown-menu"
> >
<button <button
type="button" type="button"
@ -210,17 +180,10 @@ defmodule MvWeb.CoreComponents do
aria-expanded={@open} aria-expanded={@open}
aria-controls={@id} aria-controls={@id}
aria-label={@button_label} aria-label={@button_label}
class={[ class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
phx-target={@phx_target} phx-target={@phx_target}
data-testid={@button_testid} data-testid="dropdown-button"
> >
<%= if @icon do %> <%= if @icon do %>
<.icon name={@icon} /> <.icon name={@icon} />
@ -232,79 +195,69 @@ defmodule MvWeb.CoreComponents do
:if={@open} :if={@open}
id={@id} id={@id}
role="menu" role="menu"
class={[ class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
"absolute mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box max-h-96 overflow-y-auto border border-base-300",
if(@menu_align == "left", do: "left-0", else: "right-0"),
@menu_width,
@menu_class
]}
tabindex="0" tabindex="0"
phx-target={@phx_target} phx-target={@phx_target}
data-testid={@menu_testid}
> >
<%= if assigns.inner_block != [] do %> <li :if={@show_select_buttons} role="none">
{render_slot(@inner_block)} <div class="flex justify-between items-center mb-2 px-2">
<% else %> <span class="font-semibold">{gettext("Options")}</span>
<li :if={@show_select_buttons} role="none"> <div class="flex gap-1">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
</div>
</div>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button <button
type="button" type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"} role="menuitem"
aria-label={item.label} aria-label={gettext("Select all")}
aria-checked={ phx-click="select_all"
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target} phx-target={@phx_target}
class="btn btn-xs btn-ghost"
> >
<%= if @checkboxes do %> {gettext("All")}
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button> </button>
</li> <button
<% end %> type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
</div>
</div>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<% end %> <% end %>
</ul> </ul>
</div> </div>
@ -559,7 +512,7 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)} {render_slot(@subtitle)}
</p> </p>
</div> </div>
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div> <div class="flex-none">{render_slot(@actions)}</div>
</header> </header>
""" """
end end

View file

@ -1,100 +0,0 @@
defmodule MvWeb.Components.ExportDropdown do
@moduledoc """
Export dropdown component for member export (CSV/PDF).
Provides an accessible dropdown menu with CSV and PDF export options.
Uses the same export payload as the previous single-button export.
"""
use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext
@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(:export_payload_json, assigns[:export_payload_json] || "")
|> assign(:selected_count, assigns[:selected_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
button_label =
gettext("Export") <>
" (" <>
if(assigns.selected_count == 0,
do: gettext("all"),
else: to_string(assigns.selected_count)
) <>
")"
assigns = assign(assigns, :button_label, button_label)
~H"""
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
<.dropdown_menu
id={"#{@id}-menu"}
button_label={@button_label}
icon="hero-arrow-down-tray"
open={@open}
phx_target={@myself}
menu_width="w-48"
menu_align="left"
button_class="btn-secondary gap-2"
testid="export-dropdown"
button_testid="export-dropdown-button"
menu_testid="export-dropdown-menu"
>
<li role="none">
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
<span>{gettext("CSV")}</span>
</button>
</form>
</li>
<li role="none">
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>
<.icon name="hero-document-text" class="h-4 w-4" />
<span>{gettext("PDF")}</span>
</button>
</form>
</li>
</.dropdown_menu>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
end

View file

@ -1,159 +0,0 @@
defmodule MvWeb.MemberPdfExportController do
@moduledoc """
PDF export for members.
Expects `payload` as JSON string form param.
Uses the same actor/permissions as the member overview.
"""
use MvWeb, :controller
require Logger
alias Mv.Authorization.Actor
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
alias MvWeb.Translations.MemberFields
use Gettext, backend: MvWeb.Gettext
@payload_required_message "payload required"
@invalid_json_message "invalid JSON"
@export_failed_message "Failed to generate PDF export"
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
def export(conn, %{"payload" => payload}) when is_binary(payload) do
actor = current_actor(conn)
if is_nil(actor) do
forbidden(conn)
else
locale = get_locale(conn)
club_name = get_club_name()
with {:ok, decoded} <- decode_json_map(payload),
parsed <- MemberExport.parse_params(decoded),
{:ok, export_data} <- Build.build(actor, parsed, &label_for_column/1),
{:ok, pdf_binary} <-
MembersPDF.render(export_data, locale: locale, club_name: club_name) do
filename = "members-#{Date.utc_today()}.pdf"
send_download(
conn,
{:binary, pdf_binary},
filename: filename,
content_type: "application/pdf"
)
else
{:error, :invalid_json} ->
bad_request(conn, @invalid_json_message)
{:error, :forbidden} ->
forbidden(conn)
{:error, {:row_limit_exceeded, row_count, max_rows}} ->
unprocessable_entity(conn, %{
error: "row_limit_exceeded",
message:
gettext("Export contains %{count} rows, maximum is %{max}",
count: row_count,
max: max_rows
),
row_count: row_count,
max_rows: max_rows
})
{:error, reason} ->
Logger.warning("PDF export failed: #{inspect(reason)}")
internal_error(conn, %{
error: "export_failed",
message: gettext(@export_failed_message)
})
end
end
end
def export(conn, _params) do
bad_request(conn, @payload_required_message)
end
# --- Actor / auth ---
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
defp forbidden(conn) do
conn
|> put_status(:forbidden)
|> json(%{error: "forbidden", message: "Forbidden"})
|> halt()
end
# --- Decoding / validation ---
defp decode_json_map(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> {:ok, decoded}
_ -> {:error, :invalid_json}
end
end
# --- Column labels ---
# Goal: translate known member fields to UI labels, but never crash.
# - Atoms: label directly.
# - Binaries: only translate if they are known member fields (allowlist); otherwise return the string.
# This avoids String.to_existing_atom/1 exceptions for arbitrary keys (e.g., "custom_field_...").
defp label_for_column(key) when is_atom(key) do
MemberFields.label(key)
end
defp label_for_column(key) when is_binary(key) do
if key in @allowed_member_field_strings do
# Safe because key is in allowlist which originates from existing atoms
MemberFields.label(String.to_existing_atom(key))
else
key
end
end
defp label_for_column(key) do
to_string(key)
end
# --- Locale and club name ---
defp get_locale(conn) do
conn.assigns[:locale] || Gettext.get_locale(MvWeb.Gettext) || "en"
end
defp get_club_name do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Club"
end
end
# --- JSON responses ---
defp bad_request(conn, message) when is_binary(message) do
conn
|> put_status(:bad_request)
|> json(%{error: "bad_request", message: message})
end
defp unprocessable_entity(conn, body) when is_map(body) do
conn
|> put_status(:unprocessable_entity)
|> json(body)
end
defp internal_error(conn, body) when is_map(body) do
conn
|> put_status(:internal_server_error)
|> json(body)
end
end

View file

@ -16,9 +16,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
## Props ## Props
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
- `:groups` - List of groups (for per-group filter rows)
- `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group).
Multiple active filters combine with AND (member must match all selected group conditions).
- `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:id` - Component ID (required) - `:id` - Component ID (required)
@ -26,13 +23,10 @@ defmodule MvWeb.Components.MemberFilterComponent do
## Events ## Events
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
""" """
use MvWeb, :live_component use MvWeb, :live_component
@group_filter_prefix "group_"
@impl true @impl true
def mount(socket) do def mount(socket) do
{:ok, assign(socket, :open, false)} {:ok, assign(socket, :open, false)}
@ -44,9 +38,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
socket socket
|> assign(:id, assigns.id) |> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter]) |> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:groups, assigns[:groups] || [])
|> assign(:group_filters, assigns[:group_filters] || %{})
|> assign(:group_filter_prefix, @group_filter_prefix)
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{}) |> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0) |> assign(:member_count, assigns[:member_count] || 0)
@ -69,9 +60,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
tabindex="0" tabindex="0"
class={[ class={[
"btn gap-2", "btn gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 || (@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active"
]} ]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
phx-target={@myself} phx-target={@myself}
@ -81,13 +70,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
> >
<.icon name="hero-funnel" class="h-5 w-5" /> <.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{button_label( {button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
@cycle_status_filter,
@groups,
@group_filters,
@boolean_custom_fields,
@boolean_filters
)}
</span> </span>
<span <span
:if={active_boolean_filters_count(@boolean_filters) > 0} :if={active_boolean_filters_count(@boolean_filters) > 0}
@ -96,10 +79,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{active_boolean_filters_count(@boolean_filters)} {active_boolean_filters_count(@boolean_filters)}
</span> </span>
<span <span
:if={ :if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
(@cycle_status_filter || map_size(@group_filters) > 0) &&
active_boolean_filters_count(@boolean_filters) == 0
}
class="badge badge-primary badge-sm" class="badge badge-primary badge-sm"
> >
{@member_count} {@member_count}
@ -123,7 +103,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
role="dialog" role="dialog"
aria-label={gettext("Member filter")} aria-label={gettext("Member filter")}
> >
<form phx-change="update_filters" phx-target={@myself} data-testid="member-filter-form"> <form phx-change="update_filters" phx-target={@myself}>
<!-- Payment Filter Group --> <!-- Payment Filter Group -->
<div class="mb-4"> <div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -182,73 +162,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
</fieldset> </fieldset>
</div> </div>
<!-- Groups: one row per group with All / Yes / No (like Custom Fields) -->
<div :if={length(@groups) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Groups")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
:for={group <- @groups}
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
>
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{group.name}
</legend>
<div class="join col-start-2">
<label
class={"#{group_filter_label_class(@group_filters, group.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"group-filter-#{group.id}-all"}
>
<input
type="radio"
id={"group-filter-#{group.id}-all"}
name={"#{@group_filter_prefix}#{group.id}"}
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@group_filters, to_string(group.id)) == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{group_filter_label_class(@group_filters, group.id, :in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"group-filter-#{group.id}-in"}
aria-label={gettext("Yes")}
title={gettext("Yes")}
>
<input
type="radio"
id={"group-filter-#{group.id}-in"}
name={"#{@group_filter_prefix}#{group.id}"}
value="in"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@group_filters, to_string(group.id)) == :in}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Yes")}</span>
</label>
<label
class={"#{group_filter_label_class(@group_filters, group.id, :not_in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"group-filter-#{group.id}-not-in"}
aria-label={gettext("No")}
title={gettext("No")}
>
<input
type="radio"
id={"group-filter-#{group.id}-not-in"}
name={"#{@group_filter_prefix}#{group.id}"}
value="not_in"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@group_filters, to_string(group.id)) == :not_in}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("No")}</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group --> <!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2"> <div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -361,18 +274,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
_ -> nil _ -> nil
end end
# Parse per-group filters (params keys "group_<uuid>" => "all"|"in"|"not_in")
prefix_len = String.length(@group_filter_prefix)
group_filters_parsed =
params
|> Enum.filter(fn {key, _} -> String.starts_with?(key, @group_filter_prefix) end)
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
group_id_str = String.slice(key, prefix_len, String.length(key) - prefix_len)
filter_value = parse_group_filter_value(value_str)
Map.put(acc, group_id_str, filter_value)
end)
# Parse boolean custom field filters (including nil values for "all") # Parse boolean custom field filters (including nil values for "all")
custom_boolean_filters_parsed = custom_boolean_filters_parsed =
params params
@ -387,20 +288,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
send(self(), {:payment_filter_changed, payment_filter}) send(self(), {:payment_filter_changed, payment_filter})
end end
# Update group filters - send event for each changed group
current_group_filters = socket.assigns.group_filters
all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id)))
Enum.each(group_filters_parsed, fn {group_id_str, new_value} ->
in_set = MapSet.member?(all_group_ids, group_id_str)
current_value = Map.get(current_group_filters, group_id_str)
should_send = in_set and current_value != new_value
if should_send do
send(self(), {:group_filter_changed, group_id_str, new_value})
end
end)
# Update boolean filters - send events for each changed filter # Update boolean filters - send events for each changed filter
current_filters = socket.assigns.boolean_filters current_filters = socket.assigns.boolean_filters
@ -423,7 +310,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
def handle_event("reset_filters", _params, socket) do def handle_event("reset_filters", _params, socket) do
# Send single message to reset all filters at once (performance optimization) # Send single message to reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters # This avoids N×2 load_members() calls when resetting multiple filters
send(self(), {:reset_all_filters, nil, %{}, %{}}) send(self(), {:reset_all_filters, nil, %{}})
# Close dropdown after reset # Close dropdown after reset
{:noreply, assign(socket, :open, false)} {:noreply, assign(socket, :open, false)}
@ -435,48 +322,17 @@ defmodule MvWeb.Components.MemberFilterComponent do
defp parse_tri_state("all"), do: nil defp parse_tri_state("all"), do: nil
defp parse_tri_state(_), do: nil defp parse_tri_state(_), do: nil
defp parse_group_filter_value("in"), do: :in
defp parse_group_filter_value("not_in"), do: :not_in
defp parse_group_filter_value(_), do: nil
# Get display label for button # Get display label for button
defp button_label( defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
cycle_status_filter, # If payment filter is active, show payment filter label
groups, if cycle_status_filter do
group_filters, payment_filter_label(cycle_status_filter)
boolean_custom_fields, else
boolean_filters # Otherwise show boolean filter labels
) do boolean_filter_label(boolean_custom_fields, boolean_filters)
cond do
cycle_status_filter ->
payment_filter_label(cycle_status_filter)
map_size(group_filters) > 0 ->
group_filters_label(groups, group_filters)
map_size(boolean_filters) > 0 ->
boolean_filter_label(boolean_custom_fields, boolean_filters)
true ->
gettext("All")
end end
end end
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
do: gettext("All")
defp group_filters_label(groups, group_filters) do
groups_by_id = Map.new(groups, fn g -> {to_string(g.id), g.name} end)
names =
group_filters
|> Enum.map(fn {group_id_str, _} -> Map.get(groups_by_id, group_id_str) end)
|> Enum.reject(&is_nil/1)
label = Enum.join(names, ", ")
truncate_label(label, 30)
end
# Get payment filter label # Get payment filter label
defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid") defp payment_filter_label(:paid), do: gettext("Paid")
@ -550,39 +406,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
end end
end end
# Get CSS classes for per-group filter label based on current state
defp group_filter_label_class(group_filters, group_id, expected_value) do
base_classes = "join-item btn btn-sm"
current_value = Map.get(group_filters, to_string(group_id))
is_active = current_value == expected_value
cond do
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
expected_value == :in ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
expected_value == :not_in ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
# Get CSS classes for boolean filter label based on current state # Get CSS classes for boolean filter label based on current state
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
base_classes = "join-item btn btn-sm" base_classes = "join-item btn btn-sm"

View file

@ -41,7 +41,6 @@ defmodule MvWeb.MemberLive.Index do
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
@group_filter_prefix "group_"
# Maximum number of boolean custom field filters allowed per request (DoS protection) # Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters() @max_boolean_filters Mv.Constants.max_boolean_filters()
@ -86,12 +85,6 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1.value_type == :boolean)) |> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc) |> Enum.sort_by(& &1.name, :asc)
# Load groups for filter dropdown (sorted by name)
groups =
Mv.Membership.Group
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load settings once to avoid N+1 queries # Load settings once to avoid N+1 queries
settings = settings =
case Membership.get_settings() do case Membership.get_settings() do
@ -122,8 +115,6 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil) |> assign(:cycle_status_filter, nil)
|> assign(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{}) |> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new()) |> assign(:selected_members, MapSet.new())
|> assign(:settings, settings) |> assign(:settings, settings)
@ -251,7 +242,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
new_show_current, new_show_current,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -362,7 +352,6 @@ defmodule MvWeb.MemberLive.Index do
export_sort_field(socket.assigns.sort_field), export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order), export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -388,7 +377,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -416,7 +404,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
filter, filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -450,7 +437,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
updated_filters updated_filters
) )
@ -463,55 +449,11 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_patch(socket, to: new_path, replace: true)}
end end
@impl true
def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do
normalized_id = normalize_uuid_string(group_id_str) || group_id_str
group_filters =
if filter_value == nil do
Map.delete(socket.assigns.group_filters, normalized_id)
else
Map.put(socket.assigns.group_filters, normalized_id, filter_value)
end
socket =
socket
|> assign(:group_filters, group_filters)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
group_filters,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true @impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket)
end
def handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
socket
) do
socket = socket =
socket socket
|> assign(:cycle_status_filter, cycle_status_filter) |> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:group_filters, group_filters)
|> assign(:boolean_custom_field_filters, boolean_filters) |> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members() |> load_members()
|> update_selection_assigns() |> update_selection_assigns()
@ -522,7 +464,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
cycle_status_filter, cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
boolean_filters boolean_filters
) )
@ -659,7 +600,6 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params) |> maybe_update_search(params)
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params) |> maybe_update_cycle_status_filter(params)
|> maybe_update_group_filters(params)
|> maybe_update_boolean_filters(params) |> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params) |> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?) |> assign(:fields_in_url?, fields_in_url?)
@ -693,7 +633,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters, socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection, socket.assigns.user_field_selection,
@ -787,7 +726,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -806,63 +744,50 @@ defmodule MvWeb.MemberLive.Index do
sort_field, sort_field,
sort_order, sort_order,
cycle_status_filter, cycle_status_filter,
group_filters,
show_current_cycle, show_current_cycle,
boolean_filters boolean_filters
) do ) do
base_params = build_base_params(query, sort_field, sort_order) field_str =
base_params = add_cycle_status_filter(base_params, cycle_status_filter) if is_atom(sort_field) do
base_params = add_group_filters(base_params, group_filters) Atom.to_string(sort_field)
base_params = add_show_current_cycle(base_params, show_current_cycle) else
add_boolean_filters(base_params, boolean_filters) sort_field
end end
defp build_base_params(query, sort_field, sort_order) do order_str =
%{ if is_atom(sort_order) do
"query" => query || "", Atom.to_string(sort_order)
"sort_field" => normalize_sort_field(sort_field), else
"sort_order" => normalize_sort_order(sort_order) sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"sort_order" => order_str
} }
end
defp normalize_sort_field(nil), do: "" base_params =
defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field) case cycle_status_filter do
defp normalize_sort_field(field) when is_binary(field), do: field nil -> base_params
defp normalize_sort_field(_), do: "" :paid -> Map.put(base_params, "cycle_status_filter", "paid")
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
defp normalize_sort_order(nil), do: "" base_params =
defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order) if show_current_cycle do
defp normalize_sort_order(order) when is_binary(order), do: order Map.put(base_params, "show_current_cycle", "true")
defp normalize_sort_order(_), do: "" else
base_params
end
defp add_group_filters(params, group_filters) do Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
Enum.reduce(group_filters, params, fn {group_id_str, value}, acc -> param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
param_value = if value == :in, do: "in", else: "not_in" param_value = if filter_value == true, do: "true", else: "false"
Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value) Map.put(acc, param_key, param_value)
end) end)
end end
defp add_cycle_status_filter(params, nil), do: params
defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")
defp add_cycle_status_filter(params, :unpaid),
do: Map.put(params, "cycle_status_filter", "unpaid")
defp add_cycle_status_filter(params, _), do: params
defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
defp add_show_current_cycle(params, _), do: params
defp add_boolean_filters(params, boolean_filters) do
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
end
defp add_boolean_filter({custom_field_id, filter_value}, acc) do
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
param_value = if filter_value == true, do: "true", else: "false"
Map.put(acc, param_key, param_value)
end
# ------------------------------------------------------------- # -------------------------------------------------------------
# Loading members # Loading members
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -898,14 +823,8 @@ defmodule MvWeb.MemberLive.Index do
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
# Load groups for each member (id, name, slug only)
query =
Ash.Query.load(query, groups: [:id, :name, :slug])
query = apply_search_filter(query, search_query) query = apply_search_filter(query, search_query)
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
# Use ALL custom fields for sorting (not just show_in_overview subset) # Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields custom_fields_for_sort = socket.assigns.all_custom_fields
@ -941,7 +860,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.all_custom_fields socket.assigns.all_custom_fields
) )
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # Sort in memory if needed (custom fields only; computed fields are blocked)
members = members =
if sort_after_load and if sort_after_load and
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
@ -983,51 +902,6 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Multiple group filters combine with AND: member must match all selected group conditions.
defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query
defp apply_group_filters(query, group_filters, groups) do
valid_ids =
groups
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|> Enum.reject(&is_nil/1)
|> MapSet.new()
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
member? = MapSet.member?(valid_ids, group_id_str)
if member? do
apply_one_group_filter(q, group_id_str, value)
else
q
end
end)
end
defp apply_one_group_filter(query, _group_id_str, nil), do: query
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, group_uuid} ->
Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid)))
_ ->
query
end
end
defp apply_one_group_filter(query, group_id_str, :not_in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, group_uuid} ->
Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid)))
_ ->
query
end
end
defp apply_one_group_filter(query, _, _), do: query
defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) defp apply_cycle_status_filter(members, status, show_current)
@ -1063,10 +937,6 @@ defmodule MvWeb.MemberLive.Index do
defp apply_sort_to_query(query, field, order) do defp apply_sort_to_query(query, field, order) do
cond do cond do
# Groups sort -> after load (in memory)
field in [:groups, "groups"] ->
{query, true}
# Custom field sort -> after load # Custom field sort -> after load
custom_field_sort?(field) -> custom_field_sort?(field) ->
{query, true} {query, true}
@ -1106,14 +976,12 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes] non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or field == :groups field in valid_fields or custom_field_sort?(field)
end end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field) custom_field_sort?(field) or
((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
custom_field_sort?(field)
end end
defp safe_member_field_atom_only(str) do defp safe_member_field_atom_only(str) do
@ -1156,35 +1024,14 @@ defmodule MvWeb.MemberLive.Index do
end end
defp sort_members_in_memory(members, field, order, custom_fields) do defp sort_members_in_memory(members, field, order, custom_fields) do
if field in [:groups, "groups"] do custom_field_id_str = extract_custom_field_id(field)
sort_members_by_groups(members, order)
else
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do case custom_field_id_str do
nil -> members nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end end
end end
defp sort_members_by_groups(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end)
end
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
custom_field = find_custom_field_by_id(custom_fields, id_str) custom_field = find_custom_field_by_id(custom_fields, id_str)
@ -1279,16 +1126,11 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, _), do: default defp determine_field(default, _), do: default
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
cond do if custom_field_sort?(sf) do
sf == "groups" -> if valid_sort_field?(sf), do: sf, else: default
:groups else
atom = safe_member_field_atom_only(sf)
custom_field_sort?(sf) -> if atom != nil and valid_sort_field?(atom), do: atom, else: default
if valid_sort_field?(sf), do: sf, else: default
true ->
atom = safe_member_field_atom_only(sf)
if atom != nil and valid_sort_field?(atom), do: atom, else: default
end end
end end
@ -1318,62 +1160,6 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_cycle_status_filter(socket, _params), defp maybe_update_cycle_status_filter(socket, _params),
do: assign(socket, :cycle_status_filter, nil) do: assign(socket, :cycle_status_filter, nil)
defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix
prefix_len = String.length(prefix)
group_param_entries =
params
|> Enum.filter(fn {key, _} ->
key_str = to_string(key)
String.starts_with?(key_str, prefix)
end)
filters =
Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc ->
add_group_filter_entry(acc, key, value_str, prefix_len)
end)
assign(socket, :group_filters, filters)
end
defp maybe_update_group_filters(socket, _), do: socket
defp add_group_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
group_id_str = normalize_uuid_string(raw_id)
valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length
if valid_id? do
case parse_group_filter_value(value_str) do
nil -> acc
value -> Map.put(acc, group_id_str, value)
end
else
acc
end
end
# Normalize UUID string so URL params match valid_ids (lowercase, canonical format)
defp normalize_uuid_string(raw) when is_binary(raw) do
case Ecto.UUID.cast(String.trim(raw)) do
{:ok, uuid} -> to_string(uuid)
_ -> raw
end
end
defp normalize_uuid_string(_), do: nil
defp parse_group_filter_value("in"), do: :in
defp parse_group_filter_value("not_in"), do: :not_in
defp parse_group_filter_value(val) when is_binary(val) do
parse_group_filter_value(String.trim(val))
end
defp parse_group_filter_value(_), do: nil
defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil defp determine_cycle_status_filter(_), do: nil

View file

@ -2,12 +2,20 @@
<.header> <.header>
{gettext("Members")} {gettext("Members")}
<:actions> <:actions>
<.live_component <form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
module={MvWeb.Components.ExportDropdown} <input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
id="export-dropdown" <input type="hidden" name="payload" value={@export_payload_json} />
export_payload_json={@export_payload_json} <button
selected_count={@selected_count} type="submit"
/> class="btn btn-secondary gap-2"
aria-label={gettext("Export members to CSV")}
>
<.icon name="hero-arrow-down-tray" />
{gettext("Export to CSV")} ({if @selected_count == 0,
do: gettext("all"),
else: @selected_count})
</button>
</form>
<.button <.button
class="secondary" class="secondary"
id="copy-emails-btn" id="copy-emails-btn"
@ -48,8 +56,6 @@
module={MvWeb.Components.MemberFilterComponent} module={MvWeb.Components.MemberFilterComponent}
id="member-filter" id="member-filter"
cycle_status_filter={@cycle_status_filter} cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
boolean_custom_fields={@boolean_custom_fields} boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)} member_count={length(@members)}
@ -304,34 +310,6 @@
<span class="badge badge-ghost">{gettext("No cycle")}</span> <span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %> <% end %>
</:col> </:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_groups}
field={:groups}
label={gettext("Groups")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<%= for group <- (member.groups || []) do %>
<span
class="badge badge-outline badge-primary"
role="status"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</span>
<% end %>
<%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span>
<% end %>
</:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -95,7 +95,6 @@ defmodule MvWeb.Router do
live "/admin/import-export", ImportExportLive live "/admin/import-export", ImportExportLive
post "/members/export.csv", MemberExportController, :export post "/members/export.csv", MemberExportController, :export
post "/members/export.pdf", MemberPdfExportController, :export
post "/set_locale", LocaleController, :set_locale post "/set_locale", LocaleController, :set_locale
end end

View file

@ -79,8 +79,7 @@ defmodule Mv.MixProject do
{:picosat_elixir, "~> 0.1"}, {:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"}, {:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"}, {:slugify, "~> 1.3"},
{:nimble_csv, "~> 1.0"}, {:nimble_csv, "~> 1.0"}
{:imprintor, "~> 0.5.0"}
] ]
end end

View file

@ -36,7 +36,6 @@
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"}, "igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
"imprintor": {:hex, :imprintor, "0.5.0", "3266aa8487cc6eab3915a578c79d49e489d1bacf959a6535b1ef32acc62d71cc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d4bbfbd26c2ddbb7eb38894b7412c0ef62f953cbb176df3cccbd266fe890c12f"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
@ -69,12 +68,11 @@
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"}, "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"}, "spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
"spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [:mix], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"}, "spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"},
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},

View file

@ -1,5 +0,0 @@
# This file ensures the fonts directory is tracked by git
# Place TTF font files here

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -150,7 +150,6 @@ msgstr "Hausnummer"
msgid "Notes" msgid "Notes"
msgstr "Notizen" msgstr "Notizen"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -933,7 +932,6 @@ msgstr "Vierteljährlich"
msgid "Status" msgid "Status"
msgstr "Status" msgstr "Status"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
@ -942,7 +940,6 @@ msgstr "Status"
msgid "Suspended" msgid "Suspended"
msgstr "Pausiert" msgstr "Pausiert"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -2199,9 +2196,7 @@ msgid "Group saved successfully."
msgstr "Gruppe erfolgreich gespeichert." msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "Gruppen" msgstr "Gruppen"
@ -2396,12 +2391,17 @@ msgstr "Mitgliederdaten verwalten"
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert." msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
#: lib/mv_web/components/export_dropdown.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV" msgid "Export members to CSV"
msgstr "Mitglieder importieren (CSV)" msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/components/export_dropdown.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Export to CSV"
msgstr "Nach CSV exportieren"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "all" msgid "all"
msgstr "alle" msgstr "alle"
@ -2473,11 +2473,6 @@ msgstr "Pausiert"
msgid "unpaid" msgid "unpaid"
msgstr "Unbezahlt" msgstr "Unbezahlt"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
msgstr "Mitglied der Gruppe %{name}"
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Active members" msgid "Active members"
@ -2568,57 +2563,3 @@ msgstr "Mitgliederzahlen nach Jahr als Tabelle mit Balken"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Fee types could not be loaded." msgid "Fee types could not be loaded."
msgstr "Beitragsarten konnten nicht geladen werden." msgstr "Beitragsarten konnten nicht geladen werden."
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "CSV"
msgstr "CSV"
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Created at:"
msgstr "Erstellt am:"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export"
msgstr "Nach CSV exportieren"
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Export contains %{count} rows, maximum is %{max}"
msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to PDF"
msgstr "Mitglieder als PDF exportieren"
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Failed to generate PDF export"
msgstr "Erstellen des PDF Exports ist gescheitert"
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Member %{club_name}"
msgstr "Mitglieder %{club_name}"
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Member count:"
msgstr "Anzahl Mitglieder:"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "PDF"
msgstr "PDF"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr "Benutzerdefinierte Felder"
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"

View file

@ -151,7 +151,6 @@ msgstr ""
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -934,7 +933,6 @@ msgstr ""
msgid "Status" msgid "Status"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
@ -943,7 +941,6 @@ msgstr ""
msgid "Suspended" msgid "Suspended"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -2200,9 +2197,7 @@ msgid "Group saved successfully."
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2397,12 +2392,17 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "" msgstr ""
#: lib/mv_web/components/export_dropdown.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Export members to CSV" msgid "Export members to CSV"
msgstr "" msgstr ""
#: lib/mv_web/components/export_dropdown.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Export to CSV"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "all" msgid "all"
msgstr "" msgstr ""
@ -2474,11 +2474,6 @@ msgstr ""
msgid "unpaid" msgid "unpaid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
msgstr ""
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Active members" msgid "Active members"
@ -2569,48 +2564,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Fee types could not be loaded." msgid "Fee types could not be loaded."
msgstr "" msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "CSV"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Created at:"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export"
msgstr ""
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Export contains %{count} rows, maximum is %{max}"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to PDF"
msgstr ""
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Failed to generate PDF export"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Member %{club_name}"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Member count:"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "PDF"
msgstr ""

View file

@ -13,7 +13,6 @@ msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -151,7 +150,6 @@ msgstr ""
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -675,7 +673,6 @@ msgstr ""
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Available members" msgid "Available members"
@ -934,7 +931,6 @@ msgstr ""
msgid "Status" msgid "Status"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
@ -943,7 +939,6 @@ msgstr ""
msgid "Suspended" msgid "Suspended"
msgstr "" msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -2200,9 +2195,7 @@ msgid "Group saved successfully."
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2266,66 +2259,6 @@ msgstr ""
msgid "Could not load member search. Please try again." msgid "Could not load member search. Please try again."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Add Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to remove member: %{error}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No email"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove member from group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Search for a member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Search for a member..."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add members"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove %{name}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Some members could not be added: %{errors}"
msgstr ""
#: lib/mv_web/live/import_export_live/components.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"
@ -2397,12 +2330,17 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "" msgstr ""
#: lib/mv_web/components/export_dropdown.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV" msgid "Export members to CSV"
msgstr "" msgstr ""
#: lib/mv_web/components/export_dropdown.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Export to CSV"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "all" msgid "all"
msgstr "" msgstr ""
@ -2474,11 +2412,6 @@ msgstr ""
msgid "unpaid" msgid "unpaid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
msgstr ""
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Active members" msgid "Active members"
@ -2569,53 +2502,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Fee types could not be loaded." msgid "Fee types could not be loaded."
msgstr "" msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "CSV"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Created at:"
msgstr "Created at:"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export"
msgstr ""
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Export contains %{count} rows, maximum is %{max}"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to PDF"
msgstr ""
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Failed to generate PDF export"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Member %{club_name}"
msgstr "Member %{club_name}"
#: lib/mv/membership/members_pdf.ex
#, elixir-autogen, elixir-format
msgid "Member count:"
msgstr "Member count:"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "PDF"
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr ""

View file

@ -1,95 +0,0 @@
// Typst template for member export (PDF)
// Expected sys.inputs.elixir_data:
// {
// "columns": [{"key": "...", "kind": "...", "label": "..."}, ...],
// "rows": [["cell1", "cell2", ...], ...],
// "meta": {"generated_at": "...", "member_count": 123}
// }
#set page(
paper: "a4",
flipped: true,
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
)
#set text(size: 9pt, hyphenate: true)
#set heading(numbering: none)
// Enable text wrapping in table cells
#show table.cell: it => box(width: 100%)[#it]
#let data = sys.inputs.elixir_data
#let columns = data.at("columns", default: ())
#let rows = data.at("rows", default: ())
#let meta = data.at("meta", default: (generated_at: "", member_count: rows.len()))
#let title = data.at("title", default: "Member Export")
#let created_at_label = data.at("created_at_label", default: "Created at:")
#let member_count_label = data.at("member_count_label", default: "Member count:")
// Title
#align(center)[
#text(size: 14pt, weight: "bold")[#title]
]
#v(0.4cm)
// Export metadata
#set text(size: 8pt, fill: black)
#grid(
columns: (1fr, 1fr),
gutter: 1cm,
[*#created_at_label* #meta.at("generated_at", default: "")],
[*#member_count_label* #meta.at("member_count", default: rows.len())],
)
#v(0.6cm)
// ---- Horizontal paging config ----
#let fixed_count = calc.min(2, columns.len())
#let max_dynamic_cols = 5
#let fixed_col_widths = (32mm, 32mm)
#let fixed_cols = columns.slice(0, fixed_count)
#let dynamic_cols = columns.slice(fixed_count, columns.len())
#let dynamic_chunks = dynamic_cols.chunks(max_dynamic_cols)
#let render_chunk(chunk_index, dyn_cols_chunk) = [
#let dyn_count = dyn_cols_chunk.len()
#let start = fixed_count + chunk_index * max_dynamic_cols
#let page_cols = fixed_cols + dyn_cols_chunk
#let headers = page_cols.map(c => c.at("label", default: ""))
// widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr
#let widths = (
if fixed_count >= 1 { fixed_col_widths.at(0) } else { 1fr },
if fixed_count >= 2 { fixed_col_widths.at(1) } else { 1fr },
..((1fr,) * dyn_count)
)
#let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
// Body cells (row-major), nur die Spalten dieses Chunks
#let body_cells = (
rows
.map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count))
.map(cells => cells.map(cell => text(size: 8.5pt)[#cell]))
.flatten()
)
#table(
columns: widths,
table.header(..header_cells),
..body_cells,
)
]
// ---- Output ----
#if dynamic_cols.len() == 0 {
render_chunk(0, ())
} else {
for (i, chunk) in dynamic_chunks.enumerate() {
render_chunk(i, chunk)
if i < dynamic_chunks.len() - 1 { pagebreak() }
}
}

View file

@ -1 +0,0 @@

View file

@ -145,10 +145,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|> element("[data-testid='custom_field_#{field.id}']") |> element("[data-testid='custom_field_#{field.id}']")
|> render_click() |> render_click()
# Check URL was updated (param order may vary) # Check URL was updated
path = assert_patch(view) assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
assert path =~ "sort_order=desc"
assert path =~ "sort_field=custom_field_#{field.id}"
# Verify sort state # Verify sort state
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")

View file

@ -1,178 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
@moduledoc """
Tests for accessibility of groups feature in the member overview.
Tests cover:
- Badges have role="status" and aria-label
- Filter dropdown has aria-label
- Sort header has aria-label for screen reader
- Keyboard navigation works (Tab through filter, sort header)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
%{
member1: member1,
group1: group1
}
end
@tag :ui
test "group badges have role and aria-label", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify badges have role="status" and aria-label containing the group name
assert has_element?(view, "span[role='status'][aria-label*='#{group1.name}']")
assert html =~ group1.name
# Verify member1's row contains the badge
assert html =~ member1.first_name
end
@tag :ui
test "filter dropdown has group presence section with legend", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("button[aria-label='Filter members']")
|> render_click()
html = render(view)
# Groups section: legend "Member has groups" and radios (Any / Yes / No)
assert html =~ ~r/[Gg]roups/
assert has_element?(view, "[data-testid='member-filter-form']")
end
@tag :ui
test "sort header has aria-label for screen reader", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify sort header has aria-label describing the sort state
assert has_element?(view, "[data-testid='groups'][aria-label]")
end
@tag :ui
test "keyboard navigation works for filter dropdown", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
html = render(view)
assert html =~ member1.first_name
end
@tag :ui
test "keyboard navigation works for sort header", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid='groups']")
view
|> element("[data-testid='groups']")
|> render_click()
# Verify sort was applied (URL may include other params)
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
@tag :ui
test "screen reader announcements for filter changes", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
html = render(view)
assert html =~ member1.first_name
end
@tag :ui
test "multiple badges are announced correctly", %{
conn: conn,
member1: member1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create multiple groups for member1
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify multiple badges are present
assert html =~ member1.first_name
# Both groups should be visible
# Screen reader should be able to distinguish between multiple badges
assert html
end
end

View file

@ -1,103 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
@moduledoc """
Tests for displaying groups in the member overview.
Tests cover:
- Group badges are displayed for members in groups
- Multiple badges for members in multiple groups
- No badge for members without groups
- Badge shows group name correctly
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
{:ok, _mg2} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id})
|> Ash.create(actor: system_actor)
{:ok, _mg3} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
end
test "displays group badges for members in groups", %{
conn: conn,
group1: group1,
group2: group2
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ group1.name
assert html =~ group2.name
end
test "displays multiple badges for member in multiple groups", %{
conn: conn,
member1: member1,
group1: group1,
group2: group2
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ member1.first_name
assert html =~ group1.name
assert html =~ group2.name
end
test "shows placeholder for members without groups", %{conn: conn, member3: member3} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ member3.first_name
end
test "displays group name correctly in badge", %{conn: conn, group1: group1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ group1.name
end
end

View file

@ -1,161 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
@moduledoc """
Tests for filtering members by group in the member overview.
Uses the filter dropdown (MemberFilterComponent) with one row per group:
All / Yes / No (per group). Multiple active group filters combine with AND
(member must match all selected group conditions).
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
{:ok, _mg2} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group2.id})
|> Ash.create(actor: system_actor)
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
end
defp open_filter_and_set_group(view, group_id, value) do
view
|> element("button[aria-label='Filter members']")
|> render_click()
key = "group_#{group_id}"
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{key => value, "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
_ = render(view)
assert_patch(view)
end
test "filter All (default) shows all members", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ m1.first_name
assert html =~ m2.first_name
assert html =~ m3.first_name
end
test "filter group1 Yes shows only members in group1", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "in")
html = render(view)
assert html =~ m1.first_name
refute html =~ m2.first_name
refute html =~ m3.first_name
end
test "filter group1 No shows only members not in group1", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "not_in")
html = render(view)
refute html =~ m1.first_name
assert html =~ m2.first_name
assert html =~ m3.first_name
end
test "filter persists in URL parameters", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "in")
html = render(view)
assert html =~ m1.first_name
refute html =~ m2.first_name
refute html =~ m3.first_name
{:ok, _view2, html2} = live(conn, "/members?group_#{group1.id}=in")
assert html2 =~ m1.first_name
refute html2 =~ m2.first_name
refute html2 =~ m3.first_name
end
test "filter is restored from URL on load", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?group_#{group1.id}=in")
assert html =~ m1.first_name
refute html =~ m2.first_name
refute html =~ m3.first_name
end
end

View file

@ -1,247 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
@moduledoc """
Tests for integration of groups with existing features in the member overview.
Tests cover:
- Groups column works with Field Visibility (column can be hidden)
- Groups filter works with Custom Field filters
- Groups sorting works with other sortings
- Groups work with Membership Fee Status filter
- Groups work with existing search (but not testing search integration itself)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup, CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
# Create custom field for filter integration test
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter",
value_type: :boolean,
show_in_overview: false
})
|> Ash.create(actor: system_actor)
# Create custom field value for member1
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
%{
member1: member1,
member2: member2,
group1: group1,
custom_field: custom_field
}
end
test "groups column works with field visibility", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify groups column is visible by default
assert html =~ group1.name
assert html =~ member1.first_name
# Hide groups column via field visibility dropdown
# (This tests integration with field visibility feature)
# Note: Actual implementation depends on how field visibility works
# For now, we verify the column exists and can be toggled
assert html
end
test "groups filter works with custom field filters", %{
conn: conn,
member1: member1,
group1: group1
} do
# Verify group filter applies; boolean filters live in the filter dropdown and
# are exercised in member filter tests. Here we only assert group filter works.
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
html = render(view)
assert html =~ member1.first_name
end
test "groups sorting works with other sortings", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Apply groups sorting (should combine with existing sort)
view
|> element("[data-testid='groups']")
|> render_click()
# Verify both sorts are applied (or groups sort replaces first_name sort)
html = render(view)
assert html =~ member1.first_name
assert html =~ member2.first_name
# Sort by groups was applied (URL may include query= and other default params)
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
test "groups work with membership fee status filter", %{
conn: conn,
member1: member1,
group1: group1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a membership fee type and cycle for member1
{:ok, fee_type} =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Fee",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create(actor: system_actor)
# Set member's fee type so get_last_completed_cycle finds the cycle (uses member.membership_fee_type)
{:ok, _member1} =
Mv.Membership.update_member(member1, %{membership_fee_type_id: fee_type.id},
actor: system_actor
)
{:ok, _cycle} =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
membership_fee_type_id: fee_type.id,
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
status: :paid
})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} =
live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
assert html =~ "Members"
# member1 has a group and a paid cycle; page should load with both filters
assert html =~ member1.first_name
end
test "groups work with existing search (not testing search integration)", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Apply search (this tests that filter and search work together;
# search form is in SearchBarComponent with phx-submit="search")
view
|> element("form[phx-submit='search']")
|> render_submit(%{"query" => "Alice"})
# Verify filter and search both work
html = render(view)
assert html =~ member1.first_name
refute html =~ member2.first_name
# Note: We're not testing that group names are searchable
# (that's part of Issue #5 - Search Integration)
end
test "all filters and sortings work together", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Apply sorting
view
|> element("[data-testid='groups']")
|> render_click()
# Apply search
view
|> element("form[phx-submit='search']")
|> render_submit(%{"query" => "Alice"})
# Verify group filter, sort, and search are all applied
html = render(view)
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
end

View file

@ -1,207 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
@moduledoc """
Tests for performance and N+1 query prevention for groups in the member overview.
Tests cover:
- Groups are loaded with members in a single query (preloading)
- No N+1 queries when loading members with groups
- Filter works at database level (not in-memory)
- Sort runs in-memory but uses preloaded group data (no extra DB queries)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members (enough to test performance)
members =
for i <- 1..10 do
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "Test#{i}",
email: "member#{i}@example.com"
},
actor: system_actor
)
member
end
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Group 1"})
|> Ash.create(actor: system_actor)
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Group 2"})
|> Ash.create(actor: system_actor)
# Assign members to groups (alternating pattern)
Enum.each(Enum.with_index(members), fn {member, index} ->
group_id = if rem(index, 2) == 0, do: group1.id, else: group2.id
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group_id})
|> Ash.create(actor: system_actor)
end)
%{
members: members,
group1: group1,
group2: group2
}
end
@tag :slow
test "groups are preloaded with members (no N+1 queries)", %{
conn: conn,
members: _members
} do
# This test verifies that groups are loaded efficiently
# We check query count by monitoring database queries
# Note: Actual query counting would require Ecto query logging
# For now, we verify the functionality works correctly
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify all members are loaded
Enum.each(1..10, fn i ->
assert html =~ "Member#{i}"
end)
# Verify groups are displayed (if preloaded correctly, this should work)
# If N+1 queries occurred, the page might be slow or fail
assert html
end
@tag :slow
test "filter works at database level", %{
conn: conn,
group1: group1,
members: members
} do
# This test verifies that filtering happens in the database query,
# not by filtering in-memory after loading all members
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...}
html = render(view)
# Only even-indexed members (0,2,4,6,8) are in group1
Enum.each([0, 2, 4, 6, 8], fn i ->
member = Enum.at(members, i)
assert html =~ member.first_name
end)
Enum.each([1, 3, 5, 7, 9], fn i ->
member = Enum.at(members, i)
refute html =~ member.first_name
end)
end
@tag :slow
test "sorting works at database level", %{
conn: conn,
members: _members
} do
# This test verifies that sorting happens in the database query,
# not by sorting in-memory after loading all members
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply sorting
view
|> element("[data-testid='groups']")
|> render_click()
# Verify sorting is applied
html = render(view)
# Verify members are displayed (if sorting was done in-memory,
# we'd load all members first, which is less efficient)
assert html
# Database-level sorting is more efficient for large datasets
end
@tag :slow
test "handles many members with many groups efficiently", %{
conn: conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create many members (20) with multiple groups each (use distinct emails to avoid collision with setup)
members =
for i <- 11..30 do
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "Test#{i}",
email: "member#{i}@example.com"
},
actor: system_actor
)
member
end
# Create multiple groups (use distinct names to avoid collision with setup's Group 1/2)
groups =
for i <- 1..5 do
{:ok, group} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Perf Group #{i}"})
|> Ash.create(actor: system_actor)
group
end
# Assign each member to 2-3 random groups
Enum.each(members, fn member ->
selected_groups = Enum.take_random(groups, Enum.random(2..3))
Enum.each(selected_groups, fn group ->
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|> Ash.create(actor: system_actor)
end)
end)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify all members are loaded efficiently
Enum.each(11..30, fn i ->
assert html =~ "Member#{i}"
end)
# If preloading works correctly, this should be fast
# If N+1 queries occurred, this would be very slow
assert html
end
end

View file

@ -1,69 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsSortingTest do
@moduledoc """
Tests for sorting by groups in the member overview.
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member4} =
Mv.Membership.create_member(
%{first_name: "David", last_name: "Davis", email: "david@example.com"},
actor: system_actor
)
{:ok, group_a} =
Group
|> Ash.Changeset.for_create(:create, %{name: "A Group"})
|> Ash.create(actor: system_actor)
{:ok, group_b} =
Group
|> Ash.Changeset.for_create(:create, %{name: "B Group"})
|> Ash.create(actor: system_actor)
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group_a.id})
|> Ash.create(actor: system_actor)
{:ok, _mg2} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group_b.id})
|> Ash.create(actor: system_actor)
%{member1: member1, member2: member2, member4: member4, group_a: group_a, group_b: group_b}
end
test "sorts by group name ascending", %{conn: conn, group_a: group_a} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("[data-testid='groups']")
|> render_click()
# Sort was applied: button shows ascending state and group names still visible
assert has_element?(view, "[data-testid='groups']")
html = render(view)
assert html =~ group_a.name
end
end

View file

@ -1,185 +0,0 @@
defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
@moduledoc """
Tests for URL parameter persistence for groups in the member overview.
Tests cover:
- Group presence filter is written to URL (group_presence=has_groups|no_groups)
- Group sorting is written to URL (sort_field=groups&sort_order=asc)
- URL parameters are restored on load
- URL parameters work with other parameters (query, sort_field, etc.)
- URL is bookmarkable (filter/sorting persist)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
%{
member1: member1,
member2: member2,
group1: group1
}
end
test "group filter is written to URL", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view
|> element("button[aria-label='Filter members']")
|> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
html = render(view)
assert html =~ member1.first_name
end
test "group sorting is written to URL", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on groups column header to sort
view
|> element("[data-testid='groups']")
|> render_click()
# Verify sort was applied (URL is patched with sort params)
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
test "URL parameters are restored on load", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
live(conn, "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc")
assert html =~ member1.first_name
refute html =~ member2.first_name
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
test "URL parameters work with query parameter", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Alice&group_#{group1.id}=in")
assert html =~ member1.first_name
end
test "URL parameters work with other sort fields", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
end
test "URL is bookmarkable with filter and sorting", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
bookmark_url = "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc"
{:ok, view, html} = live(conn, bookmark_url)
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end
test "handles multiple group filter parameters (uses last one)", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
# Duplicate param for same group: last wins. group_id=in then not_in -> not_in
{:ok, _view, html} =
live(conn, "/members?group_#{group1.id}=in&group_#{group1.id}=not_in")
# not_in group1: member2 and member3 (member1 is in group1)
refute html =~ member1.first_name
assert html =~ member2.first_name
end
test "handles invalid URL parameters gracefully", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
invalid_id = Ecto.UUID.generate()
{:ok, _view, html} = live(conn, "/members?group_#{invalid_id}=in")
# Unknown group id ignored, all members shown
assert html =~ member1.first_name
assert html =~ member2.first_name
end
test "handles malformed URL parameters", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?group_not-a-uuid=in")
assert html =~ member1.first_name
assert html =~ member2.first_name
end
end

View file

@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
end end
describe "export dropdown" do describe "export to CSV" do
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -535,139 +535,34 @@ defmodule MvWeb.MemberLive.IndexTest do
%{member1: m1} %{member1: m1}
end end
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do test "export button is rendered when no selection and shows (all)", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")
# Dropdown button should be present
assert html =~ ~s(data-testid="export-dropdown")
assert html =~ ~s(data-testid="export-dropdown-button")
assert html =~ "Export"
# Button text shows "all" when 0 selected (locale-dependent) # Button text shows "all" when 0 selected (locale-dependent)
assert html =~ "Export to CSV"
assert html =~ "all" or html =~ "All" assert html =~ "all" or html =~ "All"
end end
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id}) render_click(view, "select_member", %{"id" => member1.id})
html = render(view) html = render(view)
assert html =~ "Export" assert html =~ "Export to CSV"
assert html =~ "(1)" assert html =~ "(1)"
end end
test "dropdown opens and closes on click", %{conn: conn} do test "form has correct action and payload hidden input", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")
# Initially closed
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Click to open
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
# Menu should be visible
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Click to close
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
# Menu should be hidden
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
end
test "dropdown has click-away and ESC handlers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Check that click-away handler is present
assert html =~ ~s(phx-click-away="close_dropdown")
# Check that ESC handler is present
assert html =~ ~s(phx-window-keydown="close_dropdown")
assert html =~ ~s(phx-key="Escape")
end
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
# Check CSV link
assert html =~ ~s(data-testid="export-csv-link")
assert html =~ "/members/export.csv" assert html =~ "/members/export.csv"
assert html =~ ~s(name="payload") assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden") assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token") assert html =~ ~s(name="_csrf_token")
# Check PDF link
assert html =~ ~s(data-testid="export-pdf-link")
assert html =~ "/members/export.pdf"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Both forms should have the same payload
csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
assert csv_form_payload == pdf_form_payload
assert csv_form_payload != nil
end
test "dropdown has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Button should have aria-haspopup="menu"
assert html =~ ~s(aria-haspopup="menu")
# Button should have aria-expanded="false" when closed
assert html =~ ~s(aria-expanded="false")
# Button should have aria-controls pointing to menu
assert html =~ ~s(aria-controls="export-dropdown-menu")
# Open dropdown
view
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
# Button should have aria-expanded="true" when open
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
end
# Helper to extract payload value from form HTML
defp extract_payload_from_form(html, action_path) do
case Regex.run(
~r/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
html
) do
[_, payload] -> payload
_ -> nil
end
end end
end end