Merge pull request 'Implements pdf export closes #286' (#418) from feature/286_export_pdf into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #418
This commit is contained in:
carla 2026-02-16 13:41:20 +01:00
commit 49ffdcade8
33 changed files with 1851 additions and 141 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}>
<li role="none">
<form>...</form>
</li>
</.dropdown_menu>
"""
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"""
<div
class="relative"
@ -170,7 +200,7 @@ defmodule MvWeb.CoreComponents do
phx-target={@phx_target}
phx-window-keydown="close_dropdown"
phx-key="Escape"
data-testid="dropdown-menu"
data-testid={@testid}
>
<button
type="button"
@ -180,10 +210,17 @@ defmodule MvWeb.CoreComponents do
aria-expanded={@open}
aria-controls={@id}
aria-label={@button_label}
class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
class={[
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid="dropdown-button"
data-testid={@button_testid}
>
<%= if @icon do %>
<.icon name={@icon} />
@ -195,69 +232,79 @@ defmodule MvWeb.CoreComponents do
:if={@open}
id={@id}
role="menu"
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
class={[
"absolute mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box max-h-96 overflow-y-auto border border-base-300",
if(@menu_align == "left", do: "left-0", else: "right-0"),
@menu_width,
@menu_class
]}
tabindex="0"
phx-target={@phx_target}
data-testid={@menu_testid}
>
<li :if={@show_select_buttons} role="none">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
<%= if assigns.inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
<li :if={@show_select_buttons} role="none">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
</div>
</div>
</div>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<% end %>
<% end %>
</ul>
</div>
@ -512,7 +559,7 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
</header>
"""
end

View file

@ -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"""
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
<.dropdown_menu
id={"#{@id}-menu"}
button_label={@button_label}
icon="hero-arrow-down-tray"
open={@open}
phx_target={@myself}
menu_width="w-48"
menu_align="left"
button_class="btn-secondary gap-2"
testid="export-dropdown"
button_testid="export-dropdown-button"
menu_testid="export-dropdown-menu"
>
<li role="none">
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
<span>{gettext("CSV")}</span>
</button>
</form>
</li>
<li role="none">
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>
<.icon name="hero-document-text" class="h-4 w-4" />
<span>{gettext("PDF")}</span>
</button>
</form>
</li>
</.dropdown_menu>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
end

View file

@ -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

View file

@ -747,45 +747,49 @@ defmodule MvWeb.MemberLive.Index do
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_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)
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
# -------------------------------------------------------------

View file

@ -2,20 +2,12 @@
<.header>
{gettext("Members")}
<:actions>
<form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
class="btn btn-secondary gap-2"
aria-label={gettext("Export members to CSV")}
>
<.icon name="hero-arrow-down-tray" />
{gettext("Export to CSV")} ({if @selected_count == 0,
do: gettext("all"),
else: @selected_count})
</button>
</form>
<.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"

View file

@ -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

View file

@ -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

View file

@ -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"},

5
priv/fonts/.gitkeep Normal file
View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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
@ -2391,17 +2394,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"
@ -2563,3 +2561,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}"

View file

@ -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
@ -2392,17 +2395,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 ""
@ -2564,3 +2562,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 ""

View file

@ -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
@ -2259,6 +2264,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 +2395,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 ""
@ -2502,3 +2562,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 ""

View file

@ -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() }
}
}

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

@ -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/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
html
) do
[_, payload] -> payload
_ -> nil
end
end
end