diff --git a/.drone.yml b/.drone.yml index 2c8d504..ed66e3e 100644 --- a/.drone.yml +++ b/.drone.yml @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:42.97 + image: renovate/renovate:43.19 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/config/config.exs b/config/config.exs index 6720a5d..d4de2c2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -58,6 +58,12 @@ config :mv, 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. config :mv, :oidc_role_sync, admin_group_name: nil, diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 3812598..67f01c8 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -85,6 +85,7 @@ - Many-to-many relationship with groups - Groups management UI (`/groups`) - 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_=in|not_in` - 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) - Member field import diff --git a/docs/pdf-generation-imprintor.md b/docs/pdf-generation-imprintor.md new file mode 100644 index 0000000..f8ce2ee --- /dev/null +++ b/docs/pdf-generation-imprintor.md @@ -0,0 +1,71 @@ +# 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) + + diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 007309a..bcbc8d9 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -116,4 +116,30 @@ defmodule Mv.Config do defp parse_and_validate_integer(_value, default) do default 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 diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex new file mode 100644 index 0000000..ce1e98c --- /dev/null +++ b/lib/mv/membership/member_export/build.ex @@ -0,0 +1,433 @@ +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 diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex new file mode 100644 index 0000000..0d6e469 --- /dev/null +++ b/lib/mv/membership/members_pdf.ex @@ -0,0 +1,456 @@ +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 diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 60f3636..40cb800 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -151,9 +151,17 @@ defmodule MvWeb.CoreComponents do ## Examples <.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}> +
  • +
    ...
    +
  • + """ attr :id, :string, default: "dropdown-menu" - attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" + attr :items, :list, default: [], doc: "List of %{label: string, value: any} maps" attr :button_label, :string, default: "Dropdown" attr :icon, :string, default: nil attr :checkboxes, :boolean, default: false @@ -161,8 +169,30 @@ defmodule MvWeb.CoreComponents do 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 :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 + menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" + assigns = assign(assigns, :menu_testid, menu_testid) + ~H"""
    - + <%= if assigns.inner_block != [] do %> + {render_slot(@inner_block)} + <% else %> +
  • +
    + {gettext("Options")} +
    + + +
    -
  • - - - - - <%= for item <- @items do %> -
  • -
  • + + + + <%= for item <- @items do %> +
  • + +
  • + <% end %> <% end %> @@ -512,7 +559,7 @@ defmodule MvWeb.CoreComponents do {render_slot(@subtitle)}

    -
    {render_slot(@actions)}
    +
    {render_slot(@actions)}
    """ end diff --git a/lib/mv_web/components/export_dropdown.ex b/lib/mv_web/components/export_dropdown.ex new file mode 100644 index 0000000..9462193 --- /dev/null +++ b/lib/mv_web/components/export_dropdown.ex @@ -0,0 +1,100 @@ +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""" +
    + <.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" + > +
  • +
    + + + +
    +
  • +
  • +
    + + + +
    +
  • + +
    + """ + 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 diff --git a/lib/mv_web/controllers/member_pdf_export_controller.ex b/lib/mv_web/controllers/member_pdf_export_controller.ex new file mode 100644 index 0000000..63feef2 --- /dev/null +++ b/lib/mv_web/controllers/member_pdf_export_controller.ex @@ -0,0 +1,159 @@ +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 diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 9286ace..ef6f32e 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -16,6 +16,9 @@ defmodule MvWeb.Components.MemberFilterComponent do ## Props - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` + - `:groups` - List of groups (for per-group filter rows) + - `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group). + Multiple active filters combine with AND (member must match all selected group conditions). - `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:id` - Component ID (required) @@ -23,10 +26,13 @@ defmodule MvWeb.Components.MemberFilterComponent do ## Events - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes + - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes """ use MvWeb, :live_component + @group_filter_prefix "group_" + @impl true def mount(socket) do {:ok, assign(socket, :open, false)} @@ -38,6 +44,9 @@ defmodule MvWeb.Components.MemberFilterComponent do socket |> assign(:id, assigns.id) |> 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_filters, assigns[:boolean_filters] || %{}) |> assign(:member_count, assigns[:member_count] || 0) @@ -60,7 +69,9 @@ defmodule MvWeb.Components.MemberFilterComponent do tabindex="0" class={[ "btn gap-2", - (@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active" + (@cycle_status_filter || map_size(@group_filters) > 0 || + active_boolean_filters_count(@boolean_filters) > 0) && + "btn-active" ]} phx-click="toggle_dropdown" phx-target={@myself} @@ -70,7 +81,13 @@ defmodule MvWeb.Components.MemberFilterComponent do > <.icon name="hero-funnel" class="h-5 w-5" /> 0} @@ -79,7 +96,10 @@ defmodule MvWeb.Components.MemberFilterComponent do {active_boolean_filters_count(@boolean_filters)} 0) && + active_boolean_filters_count(@boolean_filters) == 0 + } class="badge badge-primary badge-sm" > {@member_count} @@ -103,7 +123,7 @@ defmodule MvWeb.Components.MemberFilterComponent do role="dialog" aria-label={gettext("Member filter")} > -
    +
    @@ -162,6 +182,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
    + +
    0} class="mb-4"> +
    + {gettext("Groups")} +
    +
    +
    + + {group.name} + +
    + + + +
    +
    +
    +
    +
    0} class="mb-2">
    @@ -274,6 +361,18 @@ defmodule MvWeb.Components.MemberFilterComponent do _ -> nil end + # Parse per-group filters (params keys "group_" => "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") custom_boolean_filters_parsed = params @@ -288,6 +387,20 @@ defmodule MvWeb.Components.MemberFilterComponent do send(self(), {:payment_filter_changed, payment_filter}) 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 current_filters = socket.assigns.boolean_filters @@ -310,7 +423,7 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}}) + send(self(), {:reset_all_filters, nil, %{}, %{}}) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -322,17 +435,48 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_tri_state("all"), 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 - defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do - # If payment filter is active, show payment filter label - if cycle_status_filter do - payment_filter_label(cycle_status_filter) - else - # Otherwise show boolean filter labels - boolean_filter_label(boolean_custom_fields, boolean_filters) + defp button_label( + cycle_status_filter, + groups, + group_filters, + boolean_custom_fields, + boolean_filters + ) do + 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 + 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 defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -406,6 +550,39 @@ defmodule MvWeb.Components.MemberFilterComponent do 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 defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do base_classes = "join-item btn btn-sm" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 881be53..59ee8f9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do @custom_field_prefix Mv.Constants.custom_field_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) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -85,6 +86,12 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1.value_type == :boolean)) |> 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 settings = case Membership.get_settings() do @@ -115,6 +122,8 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:cycle_status_filter, nil) + |> assign(:group_filters, %{}) + |> assign(:groups, groups) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:settings, settings) @@ -242,6 +251,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], new_show_current, socket.assigns.boolean_custom_field_filters ) @@ -352,6 +362,7 @@ defmodule MvWeb.MemberLive.Index do export_sort_field(socket.assigns.sort_field), export_sort_order(socket.assigns.sort_order), socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -377,6 +388,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -404,6 +416,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -437,6 +450,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, updated_filters ) @@ -449,11 +463,55 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} 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 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 |> assign(:cycle_status_filter, cycle_status_filter) + |> assign(:group_filters, group_filters) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() @@ -464,6 +522,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, boolean_filters ) @@ -600,6 +659,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_cycle_status_filter(params) + |> maybe_update_group_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) @@ -633,6 +693,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, @@ -726,6 +787,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -744,50 +806,63 @@ defmodule MvWeb.MemberLive.Index do sort_field, sort_order, cycle_status_filter, + group_filters, show_current_cycle, boolean_filters ) do - field_str = - if is_atom(sort_field) do - Atom.to_string(sort_field) - else - sort_field - end + base_params = build_base_params(query, sort_field, sort_order) + base_params = add_cycle_status_filter(base_params, cycle_status_filter) + base_params = add_group_filters(base_params, group_filters) + base_params = add_show_current_cycle(base_params, show_current_cycle) + add_boolean_filters(base_params, boolean_filters) + end - order_str = - if is_atom(sort_order) do - Atom.to_string(sort_order) - else - sort_order - end - - base_params = %{ - "query" => query, - "sort_field" => field_str, - "sort_order" => order_str + defp build_base_params(query, sort_field, sort_order) do + %{ + "query" => query || "", + "sort_field" => normalize_sort_field(sort_field), + "sort_order" => normalize_sort_order(sort_order) } + end - base_params = - case cycle_status_filter do - nil -> base_params - :paid -> Map.put(base_params, "cycle_status_filter", "paid") - :unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid") - end + defp normalize_sort_field(nil), do: "" + defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field) + defp normalize_sort_field(field) when is_binary(field), do: field + defp normalize_sort_field(_), do: "" - base_params = - if show_current_cycle do - Map.put(base_params, "show_current_cycle", "true") - else - base_params - end + defp normalize_sort_order(nil), do: "" + defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order) + defp normalize_sort_order(order) when is_binary(order), do: order + defp normalize_sort_order(_), do: "" - Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc -> - 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) + defp add_group_filters(params, group_filters) do + Enum.reduce(group_filters, params, fn {group_id_str, value}, acc -> + param_value = if value == :in, do: "in", else: "not_in" + Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value) 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 # ------------------------------------------------------------- @@ -823,8 +898,14 @@ defmodule MvWeb.MemberLive.Index do 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_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -860,7 +941,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.all_custom_fields ) - # Sort in memory if needed (custom fields only; computed fields are blocked) + # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) members = if sort_after_load and socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do @@ -902,6 +983,51 @@ defmodule MvWeb.MemberLive.Index do 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, status, show_current) @@ -937,6 +1063,10 @@ defmodule MvWeb.MemberLive.Index do defp apply_sort_to_query(query, field, order) do cond do + # Groups sort -> after load (in memory) + field in [:groups, "groups"] -> + {query, true} + # Custom field sort -> after load custom_field_sort?(field) -> {query, true} @@ -976,12 +1106,14 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) + field in valid_fields or custom_field_sort?(field) or field == :groups end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do - custom_field_sort?(field) or - ((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom)) + normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field) + + (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or + custom_field_sort?(field) end defp safe_member_field_atom_only(str) do @@ -1024,14 +1156,35 @@ defmodule MvWeb.MemberLive.Index do end defp sort_members_in_memory(members, field, order, custom_fields) do - custom_field_id_str = extract_custom_field_id(field) + if field in [:groups, "groups"] do + sort_members_by_groups(members, order) + else + custom_field_id_str = extract_custom_field_id(field) - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) + case custom_field_id_str do + nil -> members + id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) + end end end + defp sort_members_by_groups(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end) + end + defp sort_members_by_custom_field(members, id_str, order, custom_fields) do custom_field = find_custom_field_by_id(custom_fields, id_str) @@ -1126,11 +1279,16 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, _), do: default defp determine_field_after_computed_check(default, sf) when is_binary(sf) do - if custom_field_sort?(sf) do - if valid_sort_field?(sf), do: sf, else: default - else - atom = safe_member_field_atom_only(sf) - if atom != nil and valid_sort_field?(atom), do: atom, else: default + cond do + sf == "groups" -> + :groups + + custom_field_sort?(sf) -> + 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 @@ -1160,6 +1318,62 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_cycle_status_filter(socket, _params), 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("unpaid"), do: :unpaid defp determine_cycle_status_filter(_), do: nil diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 381cd63..311447b 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -2,20 +2,12 @@ <.header> {gettext("Members")} <:actions> - - - - - + <.live_component + module={MvWeb.Components.ExportDropdown} + id="export-dropdown" + export_payload_json={@export_payload_json} + selected_count={@selected_count} + /> <.button class="secondary" id="copy-emails-btn" @@ -56,6 +48,8 @@ module={MvWeb.Components.MemberFilterComponent} id="member-filter" cycle_status_filter={@cycle_status_filter} + groups={@groups} + group_filters={@group_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} @@ -310,6 +304,34 @@ {gettext("No cycle")} <% end %> + <: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 %> + + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + <:action :let={member}>
    <.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 4f8c8a5..61532ff 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -95,6 +95,7 @@ defmodule MvWeb.Router do live "/admin/import-export", ImportExportLive post "/members/export.csv", MemberExportController, :export + post "/members/export.pdf", MemberPdfExportController, :export post "/set_locale", LocaleController, :set_locale end diff --git a/mix.exs b/mix.exs index 8ca214c..6ac9e8d 100644 --- a/mix.exs +++ b/mix.exs @@ -79,7 +79,8 @@ defmodule Mv.MixProject do {:picosat_elixir, "~> 0.1"}, {:ecto_commons, "~> 0.3"}, {:slugify, "~> 1.3"}, - {:nimble_csv, "~> 1.0"} + {:nimble_csv, "~> 1.0"}, + {:imprintor, "~> 0.5.0"} ] end diff --git a/mix.lock b/mix.lock index f698fa5..0b581ff 100644 --- a/mix.lock +++ b/mix.lock @@ -36,6 +36,7 @@ "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"}, "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"}, "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"}, @@ -68,11 +69,12 @@ "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"}, "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"}, "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"}, "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", [], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"}, + "spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [:mix], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, diff --git a/priv/fonts/.gitkeep b/priv/fonts/.gitkeep new file mode 100644 index 0000000..0baaaae --- /dev/null +++ b/priv/fonts/.gitkeep @@ -0,0 +1,5 @@ +# This file ensures the fonts directory is tracked by git +# Place TTF font files here + + + diff --git a/priv/fonts/LiberationMono-Bold.ttf b/priv/fonts/LiberationMono-Bold.ttf new file mode 100644 index 0000000..2e46737 Binary files /dev/null and b/priv/fonts/LiberationMono-Bold.ttf differ diff --git a/priv/fonts/LiberationMono-BoldItalic.ttf b/priv/fonts/LiberationMono-BoldItalic.ttf new file mode 100644 index 0000000..d1f46d7 Binary files /dev/null and b/priv/fonts/LiberationMono-BoldItalic.ttf differ diff --git a/priv/fonts/LiberationMono-Italic.ttf b/priv/fonts/LiberationMono-Italic.ttf new file mode 100644 index 0000000..954c394 Binary files /dev/null and b/priv/fonts/LiberationMono-Italic.ttf differ diff --git a/priv/fonts/LiberationMono-Regular.ttf b/priv/fonts/LiberationMono-Regular.ttf new file mode 100644 index 0000000..e774859 Binary files /dev/null and b/priv/fonts/LiberationMono-Regular.ttf differ diff --git a/priv/fonts/LiberationSans-Bold.ttf b/priv/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000..dc5d57f Binary files /dev/null and b/priv/fonts/LiberationSans-Bold.ttf differ diff --git a/priv/fonts/LiberationSans-BoldItalic.ttf b/priv/fonts/LiberationSans-BoldItalic.ttf new file mode 100644 index 0000000..158488a Binary files /dev/null and b/priv/fonts/LiberationSans-BoldItalic.ttf differ diff --git a/priv/fonts/LiberationSans-Italic.ttf b/priv/fonts/LiberationSans-Italic.ttf new file mode 100644 index 0000000..25970d9 Binary files /dev/null and b/priv/fonts/LiberationSans-Italic.ttf differ diff --git a/priv/fonts/LiberationSans-Regular.ttf b/priv/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..e633985 Binary files /dev/null and b/priv/fonts/LiberationSans-Regular.ttf differ diff --git a/priv/fonts/LiberationSerif-Bold.ttf b/priv/fonts/LiberationSerif-Bold.ttf new file mode 100644 index 0000000..3c7c55b Binary files /dev/null and b/priv/fonts/LiberationSerif-Bold.ttf differ diff --git a/priv/fonts/LiberationSerif-BoldItalic.ttf b/priv/fonts/LiberationSerif-BoldItalic.ttf new file mode 100644 index 0000000..6b35d9f Binary files /dev/null and b/priv/fonts/LiberationSerif-BoldItalic.ttf differ diff --git a/priv/fonts/LiberationSerif-Italic.ttf b/priv/fonts/LiberationSerif-Italic.ttf new file mode 100644 index 0000000..54d5164 Binary files /dev/null and b/priv/fonts/LiberationSerif-Italic.ttf differ diff --git a/priv/fonts/LiberationSerif-Regular.ttf b/priv/fonts/LiberationSerif-Regular.ttf new file mode 100644 index 0000000..5e5550c Binary files /dev/null and b/priv/fonts/LiberationSerif-Regular.ttf differ diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d8f80c1..1784d4b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -150,6 +150,7 @@ msgstr "Hausnummer" msgid "Notes" msgstr "Notizen" +#: lib/mv/membership/members_pdf.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/membership_fees_component.ex @@ -932,6 +933,7 @@ msgstr "Vierteljährlich" msgid "Status" msgstr "Status" +#: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -940,6 +942,7 @@ msgstr "Status" msgid "Suspended" msgstr "Pausiert" +#: lib/mv/membership/members_pdf.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/membership_fees_component.ex @@ -2196,7 +2199,9 @@ msgid "Group saved successfully." msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" @@ -2391,17 +2396,12 @@ 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." 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/live/member_live/index.html.heex +#: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" msgstr "Mitglieder importieren (CSV)" -#: 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 +#: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "all" msgstr "alle" @@ -2473,6 +2473,11 @@ msgstr "Pausiert" msgid "unpaid" 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 #, elixir-autogen, elixir-format msgid "Active members" @@ -2563,3 +2568,57 @@ msgstr "Mitgliederzahlen nach Jahr als Tabelle mit Balken" #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." 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}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index cf55012..af24afd 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -151,6 +151,7 @@ msgstr "" msgid "Notes" msgstr "" +#: lib/mv/membership/members_pdf.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/membership_fees_component.ex @@ -933,6 +934,7 @@ msgstr "" msgid "Status" msgstr "" +#: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -941,6 +943,7 @@ msgstr "" msgid "Suspended" msgstr "" +#: lib/mv/membership/members_pdf.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/membership_fees_component.ex @@ -2197,7 +2200,9 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" @@ -2392,17 +2397,12 @@ 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." msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "Export members to CSV" msgstr "" -#: 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 +#: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "all" msgstr "" @@ -2474,6 +2474,11 @@ msgstr "" msgid "unpaid" 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 #, elixir-autogen, elixir-format msgid "Active members" @@ -2564,3 +2569,48 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 1799738..88da6ff 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -13,6 +13,7 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" @@ -150,6 +151,7 @@ msgstr "" msgid "Notes" msgstr "" +#: lib/mv/membership/members_pdf.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/membership_fees_component.ex @@ -673,6 +675,7 @@ msgstr "" msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgstr "" +#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Available members" @@ -931,6 +934,7 @@ msgstr "" msgid "Status" msgstr "" +#: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex @@ -939,6 +943,7 @@ msgstr "" msgid "Suspended" msgstr "" +#: lib/mv/membership/members_pdf.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/membership_fees_component.ex @@ -2195,7 +2200,9 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex +#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" @@ -2259,6 +2266,66 @@ msgstr "" msgid "Could not load member search. Please try again." 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 #, elixir-autogen, elixir-format, fuzzy msgid "CSV files only, maximum %{size} MB" @@ -2330,17 +2397,12 @@ 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." msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format, fuzzy msgid "Export members to CSV" msgstr "" -#: 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 +#: lib/mv_web/components/export_dropdown.ex #, elixir-autogen, elixir-format msgid "all" msgstr "" @@ -2412,6 +2474,11 @@ msgstr "" msgid "unpaid" 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 #, elixir-autogen, elixir-format, fuzzy msgid "Active members" @@ -2502,3 +2569,53 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." 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 "" diff --git a/priv/pdf_templates/members_export.typ b/priv/pdf_templates/members_export.typ new file mode 100644 index 0000000..5dca208 --- /dev/null +++ b/priv/pdf_templates/members_export.typ @@ -0,0 +1,95 @@ +// 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() } + } +} diff --git a/test/mv/membership/member_export_build_test.exs b/test/mv/membership/member_export_build_test.exs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/mv/membership/member_export_build_test.exs @@ -0,0 +1 @@ + diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/mv/membership/members_pdf_test.exs @@ -0,0 +1 @@ + diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index c8201fd..2f12fcc 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -145,8 +145,10 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> element("[data-testid='custom_field_#{field.id}']") |> render_click() - # Check URL was updated - assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + # Check URL was updated (param order may vary) + path = assert_patch(view) + assert path =~ "sort_order=desc" + assert path =~ "sort_field=custom_field_#{field.id}" # Verify sort state assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") diff --git a/test/mv_web/member_live/index_groups_accessibility_test.exs b/test/mv_web/member_live/index_groups_accessibility_test.exs new file mode 100644 index 0000000..ab9b728 --- /dev/null +++ b/test/mv_web/member_live/index_groups_accessibility_test.exs @@ -0,0 +1,178 @@ +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 diff --git a/test/mv_web/member_live/index_groups_display_test.exs b/test/mv_web/member_live/index_groups_display_test.exs new file mode 100644 index 0000000..b28b978 --- /dev/null +++ b/test/mv_web/member_live/index_groups_display_test.exs @@ -0,0 +1,103 @@ +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 diff --git a/test/mv_web/member_live/index_groups_filter_test.exs b/test/mv_web/member_live/index_groups_filter_test.exs new file mode 100644 index 0000000..782ab33 --- /dev/null +++ b/test/mv_web/member_live/index_groups_filter_test.exs @@ -0,0 +1,161 @@ +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 diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs new file mode 100644 index 0000000..3075d54 --- /dev/null +++ b/test/mv_web/member_live/index_groups_integration_test.exs @@ -0,0 +1,247 @@ +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 diff --git a/test/mv_web/member_live/index_groups_performance_test.exs b/test/mv_web/member_live/index_groups_performance_test.exs new file mode 100644 index 0000000..761c4eb --- /dev/null +++ b/test/mv_web/member_live/index_groups_performance_test.exs @@ -0,0 +1,207 @@ +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 diff --git a/test/mv_web/member_live/index_groups_sorting_test.exs b/test/mv_web/member_live/index_groups_sorting_test.exs new file mode 100644 index 0000000..068152c --- /dev/null +++ b/test/mv_web/member_live/index_groups_sorting_test.exs @@ -0,0 +1,69 @@ +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 diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs new file mode 100644 index 0000000..469b010 --- /dev/null +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -0,0 +1,185 @@ +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 diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 9d4a429..4f36795 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do end end - describe "export to CSV" do + describe "export dropdown" do setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() @@ -535,34 +535,139 @@ defmodule MvWeb.MemberLive.IndexTest do %{member1: m1} end - test "export button is rendered when no selection and shows (all)", %{conn: conn} do + test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do conn = conn_with_oidc_user(conn) {: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) - assert html =~ "Export to CSV" assert html =~ "all" or html =~ "All" end - test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do + test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") render_click(view, "select_member", %{"id" => member1.id}) html = render(view) - assert html =~ "Export to CSV" + assert html =~ "Export" assert html =~ "(1)" end - test "form has correct action and payload hidden input", %{conn: conn} do + test "dropdown opens and closes on click", %{conn: conn} do 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 =~ ~s(name="payload") assert html =~ ~s(type="hidden") 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/]*action="#{Regex.escape(action_path)}"[^>]*>.*?]*name="payload"[^>]*value="([^"]+)"/s, + html + ) do + [_, payload] -> payload + _ -> nil + end end end