Refinex CSV import and PDf export closes #299 and #433 #446

Merged
carla merged 16 commits from feat/299_plz into main 2026-02-24 16:32:32 +01:00
16 changed files with 573 additions and 88 deletions
Showing only changes of commit 9a7608f9a1 - Show all commits

View file

@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @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_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date" @computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix() @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) # 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: [] 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 = ordered_member_fields =
selectable_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) |> then(fn fields -> fields ++ groups_field end)
%{ %{
@ -420,27 +420,52 @@ defmodule Mv.Membership.MemberExport do
table_order |> Enum.filter(&(&1 in fields)) table_order |> Enum.filter(&(&1 in fields))
end end
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do defp insert_fee_type_and_computed_fields_like_table(
# Insert membership_fee_status right after membership_fee_start_date (if both selected), db_fields_ordered,
# otherwise append at the end of DB fields. computed_fields,
member_fields
) do
computed_fields = computed_fields || [] computed_fields = computed_fields || []
member_fields = member_fields || []
db_with_insert = db_with_insert =
Enum.flat_map(db_fields_ordered, fn f -> Enum.flat_map(db_fields_ordered, fn f ->
if f == @computed_insert_after and "membership_fee_status" in computed_fields do expand_field_with_computed(f, member_fields, computed_fields)
[f, "membership_fee_status"]
else
[f]
end
end) end)
remaining = # If fee type is visible but start_date was not in the list, it won't be in db_with_insert
computed_fields db_with_insert =
|> Enum.reject(&(&1 in 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 db_with_insert ++ remaining
end 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 defp normalize_computed_fields(fields) when is_list(fields) do
fields fields
|> Enum.filter(&is_binary/1) |> Enum.filter(&is_binary/1)

View file

@ -134,6 +134,10 @@ defmodule Mv.Membership.MemberExport.Build do
need_groups = "groups" in parsed.member_fields 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 = query =
Member Member
|> Ash.Query.new() |> Ash.Query.new()
@ -141,6 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|> load_custom_field_values_query(custom_field_ids_union) |> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups) |> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
query = query =
if parsed.selected_ids != [] do 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 defp sort_members_in_memory(members, field, order) when is_binary(field) do
field_atom = String.to_existing_atom(field) field_atom = String.to_existing_atom(field)
if field_atom in Mv.Constants.member_fields() do if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do
sort_by_field(members, field_atom, order) key_fn = sort_key_fn_for_field(field_atom)
compare_fn = build_compare_fn(order)
Enum.sort_by(members, key_fn, compare_fn)
else else
members members
end end
@ -207,13 +214,17 @@ defmodule Mv.Membership.MemberExport.Build do
defp sort_members_in_memory(members, _field, _order), do: members defp sort_members_in_memory(members, _field, _order), do: members
defp sort_by_field(members, field_atom, order) do defp sort_key_fn_for_field(:membership_fee_type) do
key_fn = fn member -> Map.get(member, field_atom) end fn member ->
compare_fn = build_compare_fn(order) case Map.get(member, :membership_fee_type) do
nil -> nil
Enum.sort_by(members, key_fn, compare_fn) rel -> Map.get(rel, :name)
end
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("asc"), do: fn a, b -> a <= b end
defp build_compare_fn("desc"), do: fn a, b -> b <= a end defp build_compare_fn("desc"), do: fn a, b -> b <= a end
defp build_compare_fn(_), do: fn _a, _b -> true 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 defp maybe_sort(query, field, order) when is_binary(field) do
cond do cond do
field == "groups" -> field == "groups" -> {query, true}
# Groups sort → in-memory nach dem Read (wie Tabelle) field == "membership_fee_type" -> apply_fee_type_sort(query, order)
{query, true} custom_field_sort?(field) -> {query, true}
true -> apply_standard_member_sort(query, field, order)
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
end end
rescue rescue
ArgumentError -> {query, false} ArgumentError -> {query, false}
end 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 == [], defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
do: [] do: []
@ -344,6 +370,12 @@ defmodule Mv.Membership.MemberExport.Build do
Ash.Query.load(query, groups: [:id, :name]) Ash.Query.load(query, groups: [:id, :name])
end 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, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do 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) 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 = groups_col =
if "groups" in parsed.member_fields do if "groups" in parsed.member_fields do
[ [
@ -424,7 +469,8 @@ defmodule Mv.Membership.MemberExport.Build do
end) end)
|> Enum.reject(&is_nil/1) |> 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 end
defp build_rows(members, columns, custom_fields_by_id) do 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: "" if is_binary(value), do: value, else: ""
end 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 defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
groups = Map.get(member, :groups) || [] groups = Map.get(member, :groups) || []
format_groups(groups) format_groups(groups)

View file

@ -59,6 +59,13 @@ defmodule Mv.Membership.MembersCSV do
if is_binary(value), do: value, else: "" if is_binary(value), do: value, else: ""
end 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 defp cell_value(member, %{kind: :groups, key: :groups}) do
groups = Map.get(member, :groups) || [] groups = Map.get(member, :groups) || []
format_groups(groups) format_groups(groups)

View file

@ -19,7 +19,7 @@ defmodule MvWeb.MemberExportController do
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["groups"] ["membership_fee_type", "groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -239,6 +239,10 @@ defmodule MvWeb.MemberExportController do
need_groups = "groups" in parsed.member_fields 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 = query =
Member Member
|> Ash.Query.new() |> Ash.Query.new()
@ -246,6 +250,7 @@ defmodule MvWeb.MemberExportController do
|> load_custom_field_values_query(parsed.custom_field_ids) |> load_custom_field_values_query(parsed.custom_field_ids)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups) |> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
query = query =
if parsed.selected_ids != [] do if parsed.selected_ids != [] do
@ -296,6 +301,12 @@ defmodule MvWeb.MemberExportController do
Ash.Query.load(query, groups: [:id, :name]) Ash.Query.load(query, groups: [:id, :name])
end 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) # Adds computed field values to members (e.g. membership_fee_status)
defp add_computed_fields(members, computed_fields, show_current_cycle) do defp add_computed_fields(members, computed_fields, show_current_cycle) do
if "membership_fee_status" in computed_fields 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 defp maybe_sort_export(query, field, order) when is_binary(field) do
cond do cond do
field == "groups" -> field == "groups" ->
# Groups sort → in-memory nach dem Read (wie Tabelle)
{query, true} {query, true}
field == "membership_fee_type" ->
apply_membership_fee_type_sort_export(query, order)
custom_field_sort?(field) -> custom_field_sort?(field) ->
# Custom field sort → in-memory nach dem Read (wie Tabelle)
{query, true} {query, true}
true -> true ->
field_atom = String.to_existing_atom(field) apply_member_field_sort_export(query, field, order)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end end
rescue rescue
ArgumentError -> {query, false} ArgumentError -> {query, false}
end 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) defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -488,6 +518,19 @@ defmodule MvWeb.MemberExportController do
} }
end) 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 = groups_col =
if "groups" in parsed.member_fields do if "groups" in parsed.member_fields do
[ [
@ -519,7 +562,8 @@ defmodule MvWeb.MemberExportController do
end) end)
|> Enum.reject(&is_nil/1) |> 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 end
# --- headers: use MemberFields.label for translations --- # --- headers: use MemberFields.label for translations ---
@ -559,6 +603,10 @@ defmodule MvWeb.MemberExportController do
cf.name cf.name
end end
defp membership_fee_type_field_header(_conn) do
MemberFields.label(:membership_fee_type)
end
defp groups_field_header(_conn) do defp groups_field_header(_conn) do
MemberFields.label(:groups) MemberFields.label(:groups)
end end

View file

@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do
@invalid_json_message "invalid JSON" @invalid_json_message "invalid JSON"
@export_failed_message "Failed to generate PDF export" @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 def export(conn, %{"payload" => payload}) when is_binary(payload) do
actor = current_actor(conn) actor = current_actor(conn)

View file

@ -615,7 +615,9 @@ defmodule MvWeb.MemberLive.Index do
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@impl true @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) prev_sig = build_signature(socket)
fields_in_url? = fields_in_url? =
@ -625,20 +627,7 @@ defmodule MvWeb.MemberLive.Index do
end end
url_selection = FieldSelection.parse_from_url(params) url_selection = FieldSelection.parse_from_url(params)
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
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
)
visible_member_fields = visible_member_fields =
final_selection final_selection
@ -828,6 +817,70 @@ defmodule MvWeb.MemberLive.Index do
add_boolean_filters(base_params, boolean_filters) add_boolean_filters(base_params, boolean_filters)
end 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 defp build_base_params(query, sort_field, sort_order) do
%{ %{
"query" => query || "", "query" => query || "",
@ -913,6 +966,15 @@ defmodule MvWeb.MemberLive.Index do
query = query =
Ash.Query.load(query, groups: [:id, :name, :slug]) 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_search_filter(query, search_query)
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) 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"] -> field in [:groups, "groups"] ->
{query, true} {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 -> after load
custom_field_sort?(field) -> custom_field_sort?(field) ->
{query, true} {query, true}
@ -1118,11 +1184,16 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes] non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields 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 end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do 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 (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
custom_field_sort?(field) custom_field_sort?(field)
@ -1647,13 +1718,11 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.computed_member_fields() FieldVisibility.computed_member_fields()
|> Enum.filter(&(&1 in member_fields_computed)) |> Enum.filter(&(&1 in member_fields_computed))
# Include groups in export only if it's visible in the table
member_fields_with_groups = member_fields_with_groups =
if :groups in socket.assigns[:member_fields_visible] do build_export_member_fields_list(
ordered_member_fields_db ++ ["groups"] ordered_member_fields_db,
else socket.assigns[:member_fields_visible]
ordered_member_fields_db )
end
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order) # Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
ordered_custom_field_ids = ordered_custom_field_ids =
@ -1674,7 +1743,9 @@ defmodule MvWeb.MemberLive.Index do
export_column_order( export_column_order(
ordered_member_fields_db, ordered_member_fields_db,
ordered_computed_fields, 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, query: socket.assigns[:query] || nil,
sort_field: export_sort_field(socket.assigns[:sort_field]), sort_field: export_sort_field(socket.assigns[:sort_field]),
@ -1685,6 +1756,41 @@ defmodule MvWeb.MemberLive.Index do
} }
end 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(nil), do: nil
defp export_cycle_status_filter(:paid), do: "paid" defp export_cycle_status_filter(:paid), do: "paid"
defp export_cycle_status_filter(:unpaid), do: "unpaid" 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 defp export_sort_order(o) when is_binary(o), do: o
# Build a single ordered list that matches the table order: # 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) # - 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) # - custom fields appended in the same order as table (already ordered_custom_field_ids)
defp export_column_order( defp export_column_order(
ordered_member_fields_db, ordered_member_fields_db,
ordered_computed_fields, ordered_computed_fields,
ordered_custom_field_ids ordered_custom_field_ids,
membership_fee_type_visible,
groups_visible
) do ) do
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1) db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
computed_strings = Enum.map(ordered_computed_fields, &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 # Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
db_with_computed = db_with_extras =
Enum.flat_map(db_strings, fn f -> Enum.flat_map(
if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do db_strings,
[f, "membership_fee_status"] &expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
else )
[f]
end # If fee type is visible but start_date was not in the list, append it before computed/groups
end) 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) # Any remaining computed fields not inserted above (future-proof)
remaining_computed = remaining_computed =
computed_strings 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
end end

View file

@ -331,6 +331,28 @@
> >
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
</:col> </:col>
<: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 %>
<span class="text-base-content/50">—</span>
<% end %>
</:col>
<:col <:col
:let={member} :let={member}
:if={:membership_fee_status in @member_fields_visible} :if={:membership_fee_status in @member_fields_visible}

View file

@ -28,8 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
alias Mv.Membership.Helpers.VisibilityConfig alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown. # 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). # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
@pseudo_member_fields [:membership_fee_status, :groups] @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/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status @export_only_alias :payment_status
@ -64,6 +64,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
member_fields ++ custom_field_names member_fields ++ custom_field_names
end 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 """ @doc """
Merges user field selection with global settings. Merges user field selection with global settings.

View file

@ -30,6 +30,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:country), do: gettext("Country") def label(:country), do: gettext("Country")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") 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_status), do: gettext("Membership Fee Status")
def label(:membership_fee_type), do: gettext("Fee Type")
def label(:groups), do: gettext("Groups") def label(:groups), do: gettext("Groups")
# Fallback for unknown fields # Fallback for unknown fields

View file

@ -2921,3 +2921,9 @@ msgstr "Sep."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled." msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden." 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"

View file

@ -2921,3 +2921,9 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled." msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "" 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 ""

View file

@ -2921,3 +2921,9 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled." msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "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"

View file

@ -199,6 +199,43 @@ defmodule Mv.Membership.MembersCSVTest do
assert csv =~ "M,m@m.com,Paid" assert csv =~ "M,m@m.com,Paid"
end 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 test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
member = %{ member = %{
first_name: "=SUM(A1:A10)", first_name: "=SUM(A1:A10)",

View file

@ -146,6 +146,70 @@ defmodule MvWeb.MemberExportControllerTest do
refute header =~ "unknown_field" refute header =~ "unknown_field"
end 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", %{ test "export includes membership_fee_status computed field when requested", %{
conn: conn, conn: conn,
member1: m1 member1: m1
@ -501,4 +565,40 @@ defmodule MvWeb.MemberExportControllerTest do
assert membership_idx < active_idx assert membership_idx < active_idx
end end
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 end

View file

@ -56,6 +56,14 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
assert field in result assert field in result
end) end)
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 end
describe "merge_with_global_settings/3" do describe "merge_with_global_settings/3" do
@ -278,6 +286,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
test "handles invalid input" do test "handles invalid input" do
assert FieldVisibility.get_visible_member_fields(nil) == [] assert FieldVisibility.get_visible_member_fields(nil) == []
end 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 end
describe "get_visible_custom_fields/1" do describe "get_visible_custom_fields/1" do

View file

@ -361,6 +361,18 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
assert html =~ "Alice" assert html =~ "Alice"
end 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 test "handles rapid toggling", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")