diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index b4272b0..bbfbb6e 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do alias MvWeb.MemberLive.Index.MembershipFeeStatus @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["membership_fee_status", "groups"] + ["membership_fee_type", "membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -326,10 +326,10 @@ defmodule Mv.Membership.MemberExport do # Separate groups from other fields (groups is handled as a special field, not a member field) groups_field = if "groups" in member_fields, do: ["groups"], else: [] - # final member_fields list (used for column specs order): table order + computed inserted + groups + # final member_fields list (used for column specs order): table order + fee type + computed + groups ordered_member_fields = selectable_member_fields - |> insert_computed_fields_like_table(computed_fields) + |> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields) |> then(fn fields -> fields ++ groups_field end) %{ @@ -420,27 +420,52 @@ defmodule Mv.Membership.MemberExport do table_order |> Enum.filter(&(&1 in fields)) end - defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do - # Insert membership_fee_status right after membership_fee_start_date (if both selected), - # otherwise append at the end of DB fields. + defp insert_fee_type_and_computed_fields_like_table( + db_fields_ordered, + computed_fields, + member_fields + ) do computed_fields = computed_fields || [] + member_fields = member_fields || [] db_with_insert = Enum.flat_map(db_fields_ordered, fn f -> - if f == @computed_insert_after and "membership_fee_status" in computed_fields do - [f, "membership_fee_status"] - else - [f] - end + expand_field_with_computed(f, member_fields, computed_fields) end) - remaining = - computed_fields - |> Enum.reject(&(&1 in db_with_insert)) + # If fee type is visible but start_date was not in the list, it won't be in db_with_insert + db_with_insert = + if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do + db_with_insert ++ ["membership_fee_type"] + else + db_with_insert + end + remaining = Enum.reject(computed_fields, &(&1 in db_with_insert)) db_with_insert ++ remaining end + # Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order). + defp expand_field_with_computed(f, member_fields, computed_fields) do + if f == @computed_insert_after do + extra = [] + + extra = + if "membership_fee_type" in member_fields, + do: extra ++ ["membership_fee_type"], + else: extra + + extra = + if "membership_fee_status" in computed_fields, + do: extra ++ ["membership_fee_status"], + else: extra + + [f] ++ extra + else + [f] + end + end + defp normalize_computed_fields(fields) when is_list(fields) do fields |> Enum.filter(&is_binary/1) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 9e0cc7b..9a1c03a 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -134,6 +134,10 @@ defmodule Mv.Membership.MemberExport.Build do need_groups = "groups" in parsed.member_fields + need_membership_fee_type = + "membership_fee_type" in parsed.member_fields or + parsed.sort_field == "membership_fee_type" + query = Member |> Ash.Query.new() @@ -141,6 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do |> load_custom_field_values_query(custom_field_ids_union) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_groups(need_groups) + |> maybe_load_membership_fee_type(need_membership_fee_type) query = if parsed.selected_ids != [] do @@ -196,8 +201,10 @@ defmodule Mv.Membership.MemberExport.Build do 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) + if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do + key_fn = sort_key_fn_for_field(field_atom) + compare_fn = build_compare_fn(order) + Enum.sort_by(members, key_fn, compare_fn) else members end @@ -207,13 +214,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) + defp sort_key_fn_for_field(:membership_fee_type) do + fn member -> + case Map.get(member, :membership_fee_type) do + nil -> nil + rel -> Map.get(rel, :name) + end + end end + defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) 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 @@ -245,26 +256,41 @@ defmodule Mv.Membership.MemberExport.Build do defp maybe_sort(query, field, order) when is_binary(field) do cond do - field == "groups" -> - # Groups sort → in-memory nach dem Read (wie Tabelle) - {query, true} - - custom_field_sort?(field) -> - {query, true} - - true -> - 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 + field == "groups" -> {query, true} + field == "membership_fee_type" -> apply_fee_type_sort(query, order) + custom_field_sort?(field) -> {query, true} + true -> apply_standard_member_sort(query, field, order) end rescue ArgumentError -> {query, false} end + defp apply_fee_type_sort(query, order) do + order_atom = if order == "desc", do: :desc, else: :asc + {Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false} + end + + defp apply_standard_member_sort(query, field, order) do + field_atom = String.to_existing_atom(field) + + sortable = + field_atom in (Mv.Constants.member_fields() -- [:notes]) or + field_atom == :membership_fee_type + + if sortable do + order_atom = if order == "desc", do: :desc, else: :asc + + sort_field = + if field_atom == :membership_fee_type, + do: {"membership_fee_type.name", order_atom}, + else: {field_atom, order_atom} + + {Ash.Query.sort(query, [sort_field]), false} + else + {query, false} + end + end + defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [], do: [] @@ -344,6 +370,12 @@ defmodule Mv.Membership.MemberExport.Build do Ash.Query.load(query, groups: [:id, :name]) end + defp maybe_load_membership_fee_type(query, false), do: query + + defp maybe_load_membership_fee_type(query, true) do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + 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 @@ -393,6 +425,19 @@ defmodule Mv.Membership.MemberExport.Build do } end) + membership_fee_type_col = + if "membership_fee_type" in parsed.member_fields do + [ + %{ + key: :membership_fee_type, + kind: :membership_fee_type, + label: label_fn.(:membership_fee_type) + } + ] + else + [] + end + groups_col = if "groups" in parsed.member_fields do [ @@ -424,7 +469,8 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ groups_col ++ custom_cols + # Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom + member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -454,6 +500,17 @@ defmodule Mv.Membership.MemberExport.Build do if is_binary(value), do: value, else: "" end + defp cell_value( + member, + %{kind: :membership_fee_type, key: :membership_fee_type}, + _custom_fields_by_id + ) do + case Map.get(member, :membership_fee_type) do + %{name: name} when is_binary(name) -> name + _ -> "" + end + end + defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do groups = Map.get(member, :groups) || [] format_groups(groups) diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a47af8d..3d1fdd8 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,6 +59,13 @@ defmodule Mv.Membership.MembersCSV do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) do + case Map.get(member, :membership_fee_type) do + %{name: name} when is_binary(name) -> name + _ -> "" + end + end + defp cell_value(member, %{kind: :groups, key: :groups}) do groups = Map.get(member, :groups) || [] format_groups(groups) diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 08bcba7..715f86a 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -19,7 +19,7 @@ defmodule MvWeb.MemberExportController do use Gettext, backend: MvWeb.Gettext @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["groups"] + ["membership_fee_type", "groups"] @computed_export_fields ["membership_fee_status"] @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -239,6 +239,10 @@ defmodule MvWeb.MemberExportController do need_groups = "groups" in parsed.member_fields + need_membership_fee_type = + "membership_fee_type" in parsed.member_fields or + parsed.sort_field == "membership_fee_type" + query = Member |> Ash.Query.new() @@ -246,6 +250,7 @@ defmodule MvWeb.MemberExportController do |> load_custom_field_values_query(parsed.custom_field_ids) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_groups(need_groups) + |> maybe_load_membership_fee_type(need_membership_fee_type) query = if parsed.selected_ids != [] do @@ -296,6 +301,12 @@ defmodule MvWeb.MemberExportController do Ash.Query.load(query, groups: [:id, :name]) end + defp maybe_load_membership_fee_type(query, false), do: query + + defp maybe_load_membership_fee_type(query, true) do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + end + # Adds computed field values to members (e.g. membership_fee_status) defp add_computed_fields(members, computed_fields, show_current_cycle) do if "membership_fee_status" in computed_fields do @@ -343,26 +354,45 @@ defmodule MvWeb.MemberExportController do defp maybe_sort_export(query, field, order) when is_binary(field) do cond do field == "groups" -> - # Groups sort → in-memory nach dem Read (wie Tabelle) {query, true} + field == "membership_fee_type" -> + apply_membership_fee_type_sort_export(query, order) + custom_field_sort?(field) -> - # Custom field sort → in-memory nach dem Read (wie Tabelle) {query, true} true -> - 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 + apply_member_field_sort_export(query, field, order) end rescue ArgumentError -> {query, false} end + defp apply_membership_fee_type_sort_export(query, order) do + order_atom = if order == "desc", do: :desc, else: :asc + {Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false} + end + + defp apply_member_field_sort_export(query, field, order) do + field_atom = String.to_existing_atom(field) + + sortable = + field_atom in (Mv.Constants.member_fields() -- [:notes]) or + field_atom == :membership_fee_type + + if sortable do + order_atom = if order == "desc", do: :desc, else: :asc + + sort_field = + if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom + + {Ash.Query.sort(query, [{sort_field, order_atom}]), false} + else + {query, false} + end + end + defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix) # ------------------------------------------------------------------ @@ -488,6 +518,19 @@ defmodule MvWeb.MemberExportController do } end) + membership_fee_type_col = + if "membership_fee_type" in parsed.member_fields do + [ + %{ + header: membership_fee_type_field_header(conn), + kind: :membership_fee_type, + key: :membership_fee_type + } + ] + else + [] + end + groups_col = if "groups" in parsed.member_fields do [ @@ -519,7 +562,8 @@ defmodule MvWeb.MemberExportController do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ groups_col ++ custom_cols + # Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom + member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols end # --- headers: use MemberFields.label for translations --- @@ -559,6 +603,10 @@ defmodule MvWeb.MemberExportController do cf.name end + defp membership_fee_type_field_header(_conn) do + MemberFields.label(:membership_fee_type) + end + defp groups_field_header(_conn) do MemberFields.label(:groups) end diff --git a/lib/mv_web/controllers/member_pdf_export_controller.ex b/lib/mv_web/controllers/member_pdf_export_controller.ex index 63feef2..f00c0d1 100644 --- a/lib/mv_web/controllers/member_pdf_export_controller.ex +++ b/lib/mv_web/controllers/member_pdf_export_controller.ex @@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do @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) + @allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["membership_fee_type", "groups"] def export(conn, %{"payload" => payload}) when is_binary(payload) do actor = current_actor(conn) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 218fa6f..3283b5c 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -615,7 +615,9 @@ defmodule MvWeb.MemberLive.Index do # ----------------------------------------------------------------- @impl true - def handle_params(params, _url, socket) do + def handle_params(params, url, socket) do + url = url || request_url_from_socket(socket) + params = merge_fields_param_from_uri(params, url) prev_sig = build_signature(socket) fields_in_url? = @@ -625,20 +627,7 @@ defmodule MvWeb.MemberLive.Index do end url_selection = FieldSelection.parse_from_url(params) - - merged_selection = - FieldSelection.merge_sources( - url_selection, - socket.assigns.user_field_selection, - %{} - ) - - final_selection = - FieldVisibility.merge_with_global_settings( - merged_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) + final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) visible_member_fields = final_selection @@ -828,6 +817,70 @@ defmodule MvWeb.MemberLive.Index do add_boolean_filters(base_params, boolean_filters) end + defp compute_final_field_selection(true, url_selection, socket) do + only_url = + FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) + + visible_members = FieldVisibility.get_visible_member_fields(only_url) + visible_custom = FieldVisibility.get_visible_custom_fields(only_url) + + if visible_members == [] and visible_custom == [] do + # URL had only invalid field names; fall back to session + global. + compute_final_field_selection(false, url_selection, socket) + else + only_url + end + end + + defp compute_final_field_selection(false, url_selection, socket) do + merged = + FieldSelection.merge_sources( + url_selection, + socket.assigns.user_field_selection, + %{} + ) + + FieldVisibility.merge_with_global_settings( + merged, + socket.assigns.settings, + socket.assigns.all_custom_fields + ) + end + + # On full page load conn.params has no query string; read "fields" from URI so column visibility is restored. + defp request_url_from_socket(socket) do + case socket.private[:connect_info] do + %Plug.Conn{} = conn -> Plug.Conn.request_url(conn) + _ -> nil + end + end + + defp merge_fields_param_from_uri(params, nil), do: params + + defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do + case URI.decode_query(query)["fields"] do + nil -> params + value -> Map.put(params, "fields", value) + end + end + + defp merge_fields_param_from_uri(params, %URI{}), do: params + + defp merge_fields_param_from_uri(params, url) when is_binary(url) do + case URI.parse(url).query do + nil -> + params + + q -> + case URI.decode_query(q)["fields"] do + nil -> params + value -> Map.put(params, "fields", value) + end + end + end + + defp merge_fields_param_from_uri(params, _), do: params + defp build_base_params(query, sort_field, sort_order) do %{ "query" => query || "", @@ -913,6 +966,15 @@ defmodule MvWeb.MemberLive.Index do query = Ash.Query.load(query, groups: [:id, :name, :slug]) + # Load membership_fee_type when the column is visible or when sorting by it + query = + if :membership_fee_type in socket.assigns.member_fields_visible or + socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + else + query + end + query = apply_search_filter(query, search_query) query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) @@ -1073,6 +1135,10 @@ defmodule MvWeb.MemberLive.Index do field in [:groups, "groups"] -> {query, true} + # Membership fee type sort -> by related name at DB + field in [:membership_fee_type, "membership_fee_type"] -> + {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false} + # Custom field sort -> after load custom_field_sort?(field) -> {query, true} @@ -1118,11 +1184,16 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) or field == :groups + field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do - normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field) + normalized = + cond do + field == "groups" -> :groups + field == "membership_fee_type" -> :membership_fee_type + true -> safe_member_field_atom_only(field) + end (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or custom_field_sort?(field) @@ -1647,13 +1718,11 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) - # Include groups in export only if it's visible in the table member_fields_with_groups = - if :groups in socket.assigns[:member_fields_visible] do - ordered_member_fields_db ++ ["groups"] - else - ordered_member_fields_db - end + build_export_member_fields_list( + ordered_member_fields_db, + socket.assigns[:member_fields_visible] + ) # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = @@ -1674,7 +1743,9 @@ defmodule MvWeb.MemberLive.Index do export_column_order( ordered_member_fields_db, ordered_computed_fields, - ordered_custom_field_ids + ordered_custom_field_ids, + :membership_fee_type in socket.assigns[:member_fields_visible], + :groups in socket.assigns[:member_fields_visible] ), query: socket.assigns[:query] || nil, sort_field: export_sort_field(socket.assigns[:sort_field]), @@ -1685,6 +1756,41 @@ defmodule MvWeb.MemberLive.Index do } end + defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do + if f == "membership_fee_start_date" do + extra = + if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++ + if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: [] + + [f] ++ extra + else + [f] + end + end + + defp build_export_member_fields_list(ordered_db, member_fields_visible) do + with_extras = + Enum.flat_map(ordered_db, fn f -> + if f == :membership_fee_start_date and + :membership_fee_type in (member_fields_visible || []) do + [f, :membership_fee_type] + else + [f] + end + end) + + # If fee type is visible but start_date was not in the list, append it + with_extras = + if :membership_fee_type in (member_fields_visible || []) and + :membership_fee_type not in with_extras do + with_extras ++ [:membership_fee_type] + else + with_extras + end + + if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras + end + defp export_cycle_status_filter(nil), do: nil defp export_cycle_status_filter(:paid), do: "paid" defp export_cycle_status_filter(:unpaid), do: "unpaid" @@ -1700,31 +1806,41 @@ defmodule MvWeb.MemberLive.Index do defp export_sort_order(o) when is_binary(o), do: o # Build a single ordered list that matches the table order: # - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db) - # - computed fields inserted at the correct position (membership_fee_status after membership_fee_start_date) + # - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible + # - groups appended before custom fields when visible # - custom fields appended in the same order as table (already ordered_custom_field_ids) defp export_column_order( ordered_member_fields_db, ordered_computed_fields, - ordered_custom_field_ids + ordered_custom_field_ids, + membership_fee_type_visible, + groups_visible ) do db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1) computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1) - # Place membership_fee_status right after membership_fee_start_date if present in export - db_with_computed = - Enum.flat_map(db_strings, fn f -> - if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do - [f, "membership_fee_status"] - else - [f] - end - end) + # Place membership_fee_type and membership_fee_status after membership_fee_start_date when present + db_with_extras = + Enum.flat_map( + db_strings, + &expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings) + ) + + # If fee type is visible but start_date was not in the list, append it before computed/groups + db_with_extras = + if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do + db_with_extras ++ ["membership_fee_type"] + else + db_with_extras + end # Any remaining computed fields not inserted above (future-proof) remaining_computed = computed_strings - |> Enum.reject(&(&1 in db_with_computed)) + |> Enum.reject(&(&1 in db_with_extras)) - db_with_computed ++ remaining_computed ++ ordered_custom_field_ids + result = db_with_extras ++ remaining_computed + result = if groups_visible, do: result ++ ["groups"], else: result + result ++ ordered_custom_field_ids end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index fa0f43a..fcf06c8 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -313,6 +313,28 @@ > {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + <:col :let={member} :if={:membership_fee_status in @member_fields_visible} diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 6427d4c..df20d25 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -28,8 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do alias Mv.Membership.Helpers.VisibilityConfig # Single UI key for "Membership Fee Status"; only this appears in the dropdown. - # Groups is also a pseudo field (not a DB attribute, but displayed in the table). - @pseudo_member_fields [:membership_fee_status, :groups] + # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). + @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status @@ -64,6 +64,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do member_fields ++ custom_field_names end + @doc """ + Builds field selection from URL only: fields in `url_selection` are visible, all others false. + Use when `?fields=...` is in the URL so column visibility is not merged with global settings. + """ + @spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{ + String.t() => boolean() + } + def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do + all_fields = get_all_available_fields(custom_fields) + + Enum.reduce(all_fields, %{}, fn field, acc -> + field_string = field_to_string(field) + visible = Map.get(url_selection, field_string, false) + Map.put(acc, field_string, visible) + end) + end + + def selection_from_url_only(_, _), do: %{} + @doc """ Merges user field selection with global settings. diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index c9b8cad..9e98215 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -29,6 +29,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:postal_code), do: gettext("Postal Code") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_status), do: gettext("Membership Fee Status") + def label(:membership_fee_type), do: gettext("Fee Type") def label(:groups), do: gettext("Groups") # Fallback for unknown fields diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c418dca..2f4c1b8 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2914,3 +2914,9 @@ msgstr "Sep." #, elixir-autogen, elixir-format msgid "Required for Vereinfacht integration and cannot be disabled." msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden." + +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Fee Type" +msgstr "Beitragsart" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 2e7e480..98c2b91 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2914,3 +2914,9 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Required for Vereinfacht integration and cannot be disabled." msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Fee Type" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 3c53a7e..a76c9f6 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2914,3 +2914,9 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Required for Vereinfacht integration and cannot be disabled." msgstr "Required for Vereinfacht integration and cannot be disabled." + +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Fee Type" +msgstr "Fee Type" diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs index 6b0a300..7a4dfa3 100644 --- a/test/mv/membership/members_csv_test.exs +++ b/test/mv/membership/members_csv_test.exs @@ -199,6 +199,43 @@ defmodule Mv.Membership.MembersCSVTest do assert csv =~ "M,m@m.com,Paid" end + test "membership_fee_type column exports fee type name" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Fee Type", kind: :membership_fee_type, key: :membership_fee_type} + ] + + member = %{ + first_name: "M", + email: "m@m.com", + membership_fee_type: %{id: "ft-1", name: "Standard"} + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Fee Type" + assert csv =~ "Standard" + assert csv =~ "M,m@m.com,Standard" + end + + test "membership_fee_type column exports empty when no fee type" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Fee Type", kind: :membership_fee_type, key: :membership_fee_type} + ] + + member = %{first_name: "M", email: "m@m.com", membership_fee_type: nil} + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Fee Type" + assert csv =~ "M," + refute csv =~ "Standard" + end + test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do member = %{ first_name: "=SUM(A1:A10)", diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index b7fff60..cfc89ec 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -146,6 +146,70 @@ defmodule MvWeb.MemberExportControllerTest do refute header =~ "unknown_field" end + test "export includes membership_fee_type column when requested", %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "membership_fee_type", "email"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + # Fee Type column is included (label from MemberFields.label(:membership_fee_type)) + assert header =~ "Fee Type" + assert body =~ "Alice" + end + + # Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported (append fallback) + test "export includes Fee Type when only first_name and membership_fee_type are requested (no start_date)", + %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "membership_fee_type"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + assert header =~ "Fee Type" + assert header =~ "First Name" + assert body =~ "Alice" + end + test "export includes membership_fee_status computed field when requested", %{ conn: conn, member1: m1 @@ -501,4 +565,40 @@ defmodule MvWeb.MemberExportControllerTest do assert membership_idx < active_idx end end + + describe "POST /members/export.pdf" do + test "PDF export includes Fee Type column when requested without membership_fee_start_date", + %{ + conn: conn + } do + m = + Fixtures.member_fixture(%{first_name: "PDF", last_name: "Test", email: "pdf@example.com"}) + + payload = %{ + "selected_ids" => [m.id], + "member_fields" => ["first_name", "membership_fee_type"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.pdf", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + assert get_resp_header(conn, "content-type") |> List.first() =~ "application/pdf" + + body = response(conn, 200) + + # PDF is generated successfully with Fee Type in columns (regression: fee type without start_date) + assert is_binary(body) and byte_size(body) > 0 + end + end end diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs index 83ae06d..d86893d 100644 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -56,6 +56,14 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do assert field in result end) end + + test "includes pseudo member fields (membership_fee_status, membership_fee_type, groups)" do + result = FieldVisibility.get_all_available_fields([]) + + assert :membership_fee_status in result + assert :membership_fee_type in result + assert :groups in result + end end describe "merge_with_global_settings/3" do @@ -278,6 +286,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do test "handles invalid input" do assert FieldVisibility.get_visible_member_fields(nil) == [] end + + test "returns membership_fee_type when visible in selection" do + selection = %{ + "first_name" => true, + "membership_fee_type" => true, + "groups" => false + } + + result = FieldVisibility.get_visible_member_fields(selection) + + assert :membership_fee_type in result + assert :first_name in result + refute :groups in result + end end describe "get_visible_custom_fields/1" do diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 8de9c7e..79d078b 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -361,6 +361,18 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do assert html =~ "Alice" end + test "URL with only custom field keeps custom field visible (no invalid fallback)", %{ + conn: conn, + custom_field: custom_field + } do + conn = conn_with_oidc_user(conn) + id = custom_field.id + {:ok, _view, html} = live(conn, "/members?fields=custom_field_#{id}") + + # Selection must not be treated as invalid; custom field column stays visible + assert html =~ "M001" or html =~ custom_field.name + end + test "handles rapid toggling", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members")