This commit is contained in:
carla 2026-02-13 17:21:14 +01:00
parent 4fb5b12ea7
commit baa288bff3
11 changed files with 401 additions and 780 deletions

View file

@ -194,13 +194,7 @@ defmodule Mv.Membership.MemberExport.Build do
field_atom = String.to_existing_atom(field)
if field_atom in Mv.Constants.member_fields() do
Enum.sort_by(members, fn member -> Map.get(member, field_atom) end, fn a, b ->
case order do
"asc" -> a <= b
"desc" -> b <= a
_ -> true
end
end)
sort_by_field(members, field_atom, order)
else
members
end
@ -210,6 +204,17 @@ defmodule Mv.Membership.MemberExport.Build do
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
@ -251,11 +256,6 @@ defmodule Mv.Membership.MemberExport.Build do
ArgumentError -> {query, false}
end
defp sort_after_load?(field) when is_binary(field),
do: String.starts_with?(field, @custom_field_prefix)
defp sort_after_load?(_), do: false
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
do: []

View file

@ -3,8 +3,8 @@ defmodule Mv.Membership.MembersPDF do
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. No translations/Gettext
in this module - labels come from the web layer.
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.
@ -14,15 +14,17 @@ defmodule Mv.Membership.MembersPDF do
require Logger
use Gettext, backend: MvWeb.Gettext
alias Mv.Config
@template_filename "members_export.typ"
@template_path "priv/pdf_templates/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}`.
@ -30,48 +32,46 @@ defmodule Mv.Membership.MembersPDF do
Validates row count against configured limit before processing.
"""
@spec render(map()) :: {:ok, binary()} | {:error, term()}
def render(export_data) do
@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",
row_count: row_count,
max_rows: max_rows,
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",
row_count: row_count,
column_count: length(export_data.columns)
Logger.info(
"Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})"
)
create_and_use_temp_directory(export_data)
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) do
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),
{: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",
row_count: length(export_data.rows)
)
Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})")
{:ok, pdf_binary}
else
{:error, reason} = error ->
Logger.error("PDF export failed",
error: reason,
Logger.error("PDF export failed: #{inspect(reason)}",
error_type: :pdf_export_failed
)
@ -82,8 +82,7 @@ defmodule Mv.Membership.MembersPDF do
end
{:error, reason} = error ->
Logger.error("Failed to create temp directory",
error: reason,
Logger.error("Failed to create temp directory: #{inspect(reason)}",
error_type: :temp_dir_creation_failed
)
@ -138,35 +137,52 @@ defmodule Mv.Membership.MembersPDF do
:ok
{:error, reason, _} ->
Logger.warning("Failed to cleanup temp directory",
temp_dir: temp_dir,
error: reason
)
Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}")
end
end
defp convert_to_template_format(export_data) do
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 =
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" => "Mitglieder-Export",
"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" => export_data.rows,
"rows" => rows,
"columns" =>
Enum.map(export_data.columns, fn col ->
%{
@ -178,12 +194,178 @@ defmodule Mv.Membership.MembersPDF do
"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
@ -197,37 +379,43 @@ defmodule Mv.Membership.MembersPDF do
end
defp get_extra_fonts do
# Try multiple paths: compiled app path and source path (for tests/dev)
font_paths = [
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
Enum.reduce_while(font_paths, [], fn base_path, _acc ->
case File.ls(base_path) do
{:ok, files} ->
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
{:error, _reason} ->
{:cont, []}
end
end)
|> case do
[] -> []
fonts -> fonts
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) ->
@ -235,8 +423,8 @@ defmodule Mv.Membership.MembersPDF do
if String.starts_with?(pdf_binary, "%PDF") do
{:ok, pdf_binary}
else
Logger.error("PDF compilation returned invalid format",
binary_start: String.slice(pdf_binary, 0, 20)
Logger.error(
"PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})"
)
{:error, :invalid_pdf_format}
@ -251,8 +439,7 @@ defmodule Mv.Membership.MembersPDF do
{:error, {:compile_error, reason}}
other ->
Logger.error("PDF compilation returned unexpected result",
result: inspect(other),
Logger.error("PDF compilation returned unexpected result: #{inspect(other)}",
error_type: :unexpected_result
)
@ -260,8 +447,7 @@ defmodule Mv.Membership.MembersPDF do
end
rescue
e ->
Logger.error("PDF compilation raised exception",
exception: inspect(e),
Logger.error("PDF compilation raised exception: #{inspect(e)}",
error_type: :compile_exception
)

View file

@ -171,11 +171,21 @@ defmodule MvWeb.CoreComponents do
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 :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')"
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)"
@ -200,7 +210,14 @@ 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", @button_class]}
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={@button_testid}

View file

@ -29,7 +29,10 @@ defmodule MvWeb.Components.ExportDropdown do
button_label =
gettext("Export") <>
" (" <>
(if assigns.selected_count == 0, do: gettext("all"), else: to_string(assigns.selected_count)) <>
if(assigns.selected_count == 0,
do: gettext("all"),
else: to_string(assigns.selected_count)
) <>
")"
assigns = assign(assigns, :button_label, button_label)

View file

@ -25,50 +25,52 @@ defmodule MvWeb.MemberPdfExportController do
def export(conn, %{"payload" => payload}) when is_binary(payload) do
actor = current_actor(conn)
cond do
is_nil(actor) ->
forbidden(conn)
if is_nil(actor) do
forbidden(conn)
else
locale = get_locale(conn)
club_name = get_club_name()
true ->
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) do
filename = "members-#{Date.utc_today()}.pdf"
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)
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, :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, {: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)}")
{:error, reason} ->
Logger.warning("PDF export failed: #{inspect(reason)}")
internal_error(conn, %{
error: "export_failed",
message: gettext(@export_failed_message)
})
end
internal_error(conn, %{
error: "export_failed",
message: gettext(@export_failed_message)
})
end
end
end
@ -122,6 +124,19 @@ defmodule MvWeb.MemberPdfExportController 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

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