Implements pdf export closes #286 #418
33 changed files with 1851 additions and 141 deletions
|
|
@ -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,
|
||||
|
|
|
|||
71
docs/pdf-generation-imprintor.md
Normal file
71
docs/pdf-generation-imprintor.md
Normal 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)
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
433
lib/mv/membership/member_export/build.ex
Normal file
433
lib/mv/membership/member_export/build.ex
Normal 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
|
||||
456
lib/mv/membership/members_pdf.ex
Normal file
456
lib/mv/membership/members_pdf.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
100
lib/mv_web/components/export_dropdown.ex
Normal file
100
lib/mv_web/components/export_dropdown.ex
Normal 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
|
||||
159
lib/mv_web/controllers/member_pdf_export_controller.ex
Normal file
159
lib/mv_web/controllers/member_pdf_export_controller.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
# -------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
mix.exs
3
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
|
||||
|
||||
|
|
|
|||
4
mix.lock
4
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"},
|
||||
|
|
|
|||
5
priv/fonts/.gitkeep
Normal file
5
priv/fonts/.gitkeep
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# This file ensures the fonts directory is tracked by git
|
||||
# Place TTF font files here
|
||||
|
||||
|
||||
|
||||
BIN
priv/fonts/LiberationMono-Bold.ttf
Normal file
BIN
priv/fonts/LiberationMono-Bold.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationMono-BoldItalic.ttf
Normal file
BIN
priv/fonts/LiberationMono-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationMono-Italic.ttf
Normal file
BIN
priv/fonts/LiberationMono-Italic.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationMono-Regular.ttf
Normal file
BIN
priv/fonts/LiberationMono-Regular.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSans-Bold.ttf
Normal file
BIN
priv/fonts/LiberationSans-Bold.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSans-BoldItalic.ttf
Normal file
BIN
priv/fonts/LiberationSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSans-Italic.ttf
Normal file
BIN
priv/fonts/LiberationSans-Italic.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSans-Regular.ttf
Normal file
BIN
priv/fonts/LiberationSans-Regular.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSerif-Bold.ttf
Normal file
BIN
priv/fonts/LiberationSerif-Bold.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSerif-BoldItalic.ttf
Normal file
BIN
priv/fonts/LiberationSerif-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSerif-Italic.ttf
Normal file
BIN
priv/fonts/LiberationSerif-Italic.ttf
Normal file
Binary file not shown.
BIN
priv/fonts/LiberationSerif-Regular.ttf
Normal file
BIN
priv/fonts/LiberationSerif-Regular.ttf
Normal file
Binary file not shown.
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
95
priv/pdf_templates/members_export.typ
Normal file
95
priv/pdf_templates/members_export.typ
Normal 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() }
|
||||
}
|
||||
}
|
||||
1
test/mv/membership/member_export_build_test.exs
Normal file
1
test/mv/membership/member_export_build_test.exs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
test/mv/membership/members_pdf_test.exs
Normal file
1
test/mv/membership/members_pdf_test.exs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue