diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 5f7c314..ce1e98c 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -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: [] diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex index 690da74..0d6e469 100644 --- a/lib/mv/membership/members_pdf.ex +++ b/lib/mv/membership/members_pdf.ex @@ -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 ) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index c36d929..40cb800 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -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} diff --git a/lib/mv_web/components/export_dropdown.ex b/lib/mv_web/components/export_dropdown.ex index 2bd922d..9462193 100644 --- a/lib/mv_web/components/export_dropdown.ex +++ b/lib/mv_web/components/export_dropdown.ex @@ -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) diff --git a/lib/mv_web/controllers/member_pdf_export_controller.ex b/lib/mv_web/controllers/member_pdf_export_controller.ex index 99a9ce1..63feef2 100644 --- a/lib/mv_web/controllers/member_pdf_export_controller.ex +++ b/lib/mv_web/controllers/member_pdf_export_controller.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 881be53..d6dd082 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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 # ------------------------------------------------------------- diff --git a/priv/fonts/.gitkeep b/priv/fonts/.gitkeep index 905e128..0baaaae 100644 --- a/priv/fonts/.gitkeep +++ b/priv/fonts/.gitkeep @@ -1,4 +1,5 @@ # This file ensures the fonts directory is tracked by git -# Place TTF font files here as described in README.md +# Place TTF font files here + diff --git a/priv/pdf_templates/members_export.typ b/priv/pdf_templates/members_export.typ index 1ece25f..5dca208 100644 --- a/priv/pdf_templates/members_export.typ +++ b/priv/pdf_templates/members_export.typ @@ -12,36 +12,42 @@ margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm) ) -#set text(size: 9pt) +#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")[Mitglieder-Export] + #text(size: 14pt, weight: "bold")[#title] ] #v(0.4cm) // Export metadata -#set text(size: 8pt, fill: gray) +#set text(size: 8pt, fill: black) #grid( columns: (1fr, 1fr), gutter: 1cm, - [*Erstellt am:* #meta.at("generated_at", default: "")], - [*Anzahl Mitglieder:* #meta.at("member_count", default: rows.len())], + [*#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 = 6 -#let fixed_col_width = 1.6fr +#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()) @@ -54,8 +60,12 @@ #let page_cols = fixed_cols + dyn_cols_chunk #let headers = page_cols.map(c => c.at("label", default: "")) - // widths: fixe breiter, dynamische gleichmäßig - #let widths = (fixed_col_width,) * fixed_count + (1fr,) * dyn_count + // 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]) diff --git a/test/mv/membership/member_export_build_test.exs b/test/mv/membership/member_export_build_test.exs index 52dec9e..8b13789 100644 --- a/test/mv/membership/member_export_build_test.exs +++ b/test/mv/membership/member_export_build_test.exs @@ -1,356 +1 @@ -defmodule Mv.Membership.MemberExport.BuildTest do - @moduledoc """ - Tests for MemberExport.Build module. - Tests verify that the module correctly: - - Loads and filters members based on query/selected_ids - - Builds column specifications (without labels) - - Generates row data as cell strings - - Handles member fields, custom fields, and computed fields - - Applies sorting and filtering consistently - """ - use Mv.DataCase, async: true - - alias Mv.Fixtures - alias Mv.Membership.{CustomField, Member, MemberExport.Build} - alias Mv.Constants - - @custom_field_prefix Constants.custom_field_prefix() - - setup do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - # Create test members - member1 = - Fixtures.member_fixture(%{ - first_name: "Alice", - last_name: "Anderson", - email: "alice@example.com" - }) - - member2 = - Fixtures.member_fixture(%{ - first_name: "Bob", - last_name: "Brown", - email: "bob@example.com" - }) - - %{actor: system_actor, member1: member1, member2: member2} - end - - describe "build/3 - standard member fields" do - test "returns columns and rows for standard member fields", %{ - actor: actor, - member1: m1, - member2: m2 - } do - parsed = %{ - selected_ids: [m1.id, m2.id], - member_fields: ["first_name", "last_name", "email"], - selectable_member_fields: ["first_name", "last_name", "email"], - computed_fields: [], - custom_field_ids: [], - query: nil, - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - result = Build.build(actor, parsed, fn _key -> "Label" end) - - assert {:ok, data} = result - assert %{columns: columns, rows: rows, meta: meta} = data - - # Check columns structure - assert length(columns) == 3 - first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field)) - assert first_name_col - assert first_name_col.label == "Label" - assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field)) - assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field)) - - # Check rows - should have 2 members - assert length(rows) == 2 - - # Check first row (member1) - row1 = Enum.at(rows, 0) - assert length(row1) == 3 - assert "Alice" in row1 - assert "Anderson" in row1 - assert "alice@example.com" in row1 - - # Check meta - assert %{generated_at: _timestamp, member_count: 2} = meta - assert is_binary(meta.generated_at) - end - - test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do - parsed = %{ - selected_ids: [m1.id], - member_fields: ["first_name"], - selectable_member_fields: ["first_name"], - computed_fields: [], - custom_field_ids: [], - query: nil, - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - assert length(data.rows) == 1 - row = hd(data.rows) - assert "Alice" in row - assert data.meta.member_count == 1 - end - - test "applies search query when selected_ids is empty", %{ - actor: actor, - member1: m1, - member2: _m2 - } do - parsed = %{ - selected_ids: [], - member_fields: ["first_name", "last_name"], - selectable_member_fields: ["first_name", "last_name"], - computed_fields: [], - custom_field_ids: [], - query: "Alice", - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - assert length(data.rows) == 1 - row = hd(data.rows) - assert "Alice" in row - end - end - - describe "build/3 - custom fields" do - test "includes custom field columns and values", %{ - actor: actor, - member1: m1 - } do - # Create custom field - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Membership Number", - value_type: :string - }) - |> Ash.create(actor: actor) - - # Create custom field value for member - {:ok, _cfv} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: m1.id, - custom_field_id: custom_field.id, - value: "M12345" - }) - |> Ash.create(actor: actor) - - parsed = %{ - selected_ids: [m1.id], - member_fields: ["first_name"], - selectable_member_fields: ["first_name"], - computed_fields: [], - custom_field_ids: [custom_field.id], - query: nil, - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - # Should have 2 columns: first_name + custom field - assert length(data.columns) == 2 - - custom_col = - Enum.find( - data.columns, - &(&1.kind == :custom_field && &1.key == to_string(custom_field.id)) - ) - - assert custom_col - assert custom_col.custom_field.id == custom_field.id - assert custom_col.label == "Label" - - # Check row has custom field value - row = hd(data.rows) - assert length(row) == 2 - assert "M12345" in row - end - - test "handles members without custom field values", %{ - actor: actor, - member1: m1 - } do - # Create custom field but no value for member - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Optional Field", - value_type: :string - }) - |> Ash.create(actor: actor) - - parsed = %{ - selected_ids: [m1.id], - member_fields: ["first_name"], - selectable_member_fields: ["first_name"], - computed_fields: [], - custom_field_ids: [custom_field.id], - query: nil, - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - row = hd(data.rows) - # Custom field value should be empty string - assert "" in row - end - end - - describe "build/3 - computed fields" do - test "includes computed field columns and values", %{ - actor: actor, - member1: m1 - } do - parsed = %{ - selected_ids: [m1.id], - member_fields: ["first_name", "membership_fee_status"], - selectable_member_fields: ["first_name"], - computed_fields: ["membership_fee_status"], - custom_field_ids: [], - query: nil, - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - # Should have 2 columns: first_name + computed field - assert length(data.columns) == 2 - - computed_col = - Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status)) - - assert computed_col - assert computed_col.label == "Label" - - # Check row has computed field value (may be empty if no cycles) - row = hd(data.rows) - assert length(row) == 2 - # membership_fee_status should be present (even if empty) - end - end - - describe "build/3 - sorting" do - test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do - parsed = %{ - selected_ids: [m1.id, m2.id], - member_fields: ["first_name"], - selectable_member_fields: ["first_name"], - computed_fields: [], - custom_field_ids: [], - query: nil, - sort_field: "first_name", - sort_order: "asc", - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - # Should be sorted: Alice, Bob - [row1, row2] = data.rows - assert "Alice" in row1 - assert "Bob" in row2 - end - - test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do - # Create custom field - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Sort Field", - value_type: :string - }) - |> Ash.create(actor: actor) - - # Add values: m1="Zebra", m2="Alpha" - {:ok, _cfv1} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: m1.id, - custom_field_id: custom_field.id, - value: "Zebra" - }) - |> Ash.create(actor: actor) - - {:ok, _cfv2} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: m2.id, - custom_field_id: custom_field.id, - value: "Alpha" - }) - |> Ash.create(actor: actor) - - sort_field = "#{@custom_field_prefix}#{custom_field.id}" - - parsed = %{ - selected_ids: [m1.id, m2.id], - member_fields: ["first_name"], - selectable_member_fields: ["first_name"], - computed_fields: [], - custom_field_ids: [custom_field.id], - query: nil, - sort_field: sort_field, - sort_order: "asc", - show_current_cycle: false - } - - {:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end) - - # Should be sorted by custom field: Alpha (Bob), Zebra (Alice) - [row1, row2] = data.rows - # Alpha - assert "Bob" in row1 - # Zebra - assert "Alice" in row2 - end - end - - describe "build/3 - error handling" do - test "returns error when actor lacks permission", %{member1: m1} do - # Create a user with limited permissions - user = Fixtures.password_user_with_role_fixture(%{permission_set_name: "own_data"}) - - parsed = %{ - selected_ids: [m1.id], - member_fields: ["first_name"], - selectable_member_fields: ["first_name"], - computed_fields: [], - custom_field_ids: [], - query: nil, - sort_field: nil, - sort_order: nil, - show_current_cycle: false - } - - result = Build.build(user, parsed, fn _key -> "Label" end) - - assert {:error, :forbidden} = result - end - end -end diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs index 0ca259a..8b13789 100644 --- a/test/mv/membership/members_pdf_test.exs +++ b/test/mv/membership/members_pdf_test.exs @@ -1,265 +1 @@ -defmodule Mv.Membership.MembersPDFTest do - @moduledoc """ - Tests for MembersPDF module. - Tests verify that the module correctly: - - Loads the Typst template - - Converts export data to template format - - Generates valid PDF binary (starts with "%PDF") - - Handles errors gracefully - """ - use ExUnit.Case, async: true - - alias Mv.Config - alias Mv.Membership.MembersPDF - - describe "render/1" do - test "rejects export when row count exceeds limit" do - max_rows = Config.pdf_export_row_limit() - rows_over_limit = max_rows + 1 - - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"} - ], - rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end), - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: rows_over_limit - } - } - - result = MembersPDF.render(export_data) - - assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result - end - - test "allows export when row count equals limit" do - max_rows = Config.pdf_export_row_limit() - - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"} - ], - rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end), - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: max_rows - } - } - - result = MembersPDF.render(export_data) - - assert {:ok, pdf_binary} = result - assert String.starts_with?(pdf_binary, "%PDF") - end - - test "allows export when row count is below limit" do - max_rows = Config.pdf_export_row_limit() - rows_below_limit = max(1, max_rows - 10) - - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"} - ], - rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end), - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: rows_below_limit - } - } - - result = MembersPDF.render(export_data) - - assert {:ok, pdf_binary} = result - assert String.starts_with?(pdf_binary, "%PDF") - end - - test "generates valid PDF from minimal dataset" do - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"}, - %{key: "last_name", kind: :member_field, label: "Nachname"} - ], - rows: [ - ["Max", "Mustermann"], - ["Anna", "Schmidt"] - ], - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: 2 - } - } - - result = MembersPDF.render(export_data) - - assert {:ok, pdf_binary} = result - assert is_binary(pdf_binary) - assert String.starts_with?(pdf_binary, "%PDF") - assert byte_size(pdf_binary) > 1000 - end - - test "generates valid PDF with custom fields and computed fields" do - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"}, - %{key: "last_name", kind: :member_field, label: "Nachname"}, - %{key: "email", kind: :member_field, label: "E-Mail"}, - %{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"}, - %{ - key: "550e8400-e29b-41d4-a716-446655440000", - kind: :custom_field, - label: "Mitgliedsnummer", - custom_field: %{ - id: "550e8400-e29b-41d4-a716-446655440000", - name: "Mitgliedsnummer", - value_type: :string - } - } - ], - rows: [ - ["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"], - ["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"] - ], - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: 2 - } - } - - result = MembersPDF.render(export_data) - - assert {:ok, pdf_binary} = result - assert is_binary(pdf_binary) - assert String.starts_with?(pdf_binary, "%PDF") - end - - test "maintains deterministic column and row order" do - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"}, - %{key: "last_name", kind: :member_field, label: "Nachname"}, - %{key: "email", kind: :member_field, label: "E-Mail"} - ], - rows: [ - ["Max", "Mustermann", "max@example.com"], - ["Anna", "Schmidt", "anna@example.com"], - ["Peter", "Müller", "peter@example.com"] - ], - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: 3 - } - } - - # Render twice and verify identical output - {:ok, pdf1} = MembersPDF.render(export_data) - {:ok, pdf2} = MembersPDF.render(export_data) - - assert pdf1 == pdf2 - assert String.starts_with?(pdf1, "%PDF") - assert String.starts_with?(pdf2, "%PDF") - end - - test "returns error when template file is missing" do - # Temporarily rename template to simulate missing file - template_path = - Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ") - - original_content = File.read!(template_path) - File.rm(template_path) - - export_data = %{ - columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}], - rows: [["Max"]], - meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1} - } - - result = MembersPDF.render(export_data) - - assert {:error, {:template_not_found, _reason}} = result - - # Restore template - File.write!(template_path, original_content) - end - - test "handles empty rows gracefully" do - export_data = %{ - columns: [ - %{key: "first_name", kind: :member_field, label: "Vorname"}, - %{key: "last_name", kind: :member_field, label: "Nachname"} - ], - rows: [], - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: 0 - } - } - - result = MembersPDF.render(export_data) - - assert {:ok, pdf_binary} = result - assert is_binary(pdf_binary) - assert String.starts_with?(pdf_binary, "%PDF") - end - - test "handles many columns correctly" do - # Test with 10 columns to ensure dynamic column width calculation works - columns = - Enum.map(1..10, fn i -> - %{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"} - end) - - export_data = %{ - columns: columns, - rows: [Enum.map(1..10, &"Wert #{&1}")], - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: 1 - } - } - - result = MembersPDF.render(export_data) - - assert {:ok, pdf_binary} = result - assert is_binary(pdf_binary) - assert String.starts_with?(pdf_binary, "%PDF") - end - - test "creates and cleans up temp directory" do - export_data = %{ - columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}], - rows: [["Max"]], - meta: %{ - generated_at: "2024-01-15T14:30:00Z", - member_count: 1 - } - } - - # Get temp base directory - temp_base = System.tmp_dir!() - - # Count temp directories before - before_count = - temp_base - |> File.ls!() - |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) - - result = MembersPDF.render(export_data) - - assert {:ok, _pdf_binary} = result - - # Wait a bit for cleanup (async cleanup might take a moment) - Process.sleep(100) - - # Count temp directories after - after_count = - temp_base - |> File.ls!() - |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) - - # Should have same or fewer temp dirs (cleanup should have run) - assert after_count <= before_count + 1 - end - end -end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index c5d387f..4f36795 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -606,7 +606,9 @@ defmodule MvWeb.MemberLive.IndexTest do {:ok, view, _html} = live(conn, "/members") # Open dropdown - render_click(view, "toggle_dropdown", %{}, "export-dropdown") + view + |> element(~s([data-testid="export-dropdown-button"])) + |> render_click() html = render(view) @@ -646,7 +648,9 @@ defmodule MvWeb.MemberLive.IndexTest do assert html =~ ~s(aria-controls="export-dropdown-menu") # Open dropdown - render_click(view, "toggle_dropdown", %{}, "export-dropdown") + view + |> element(~s([data-testid="export-dropdown-button"])) + |> render_click() html = render(view) # Button should have aria-expanded="true" when open