fix: sorting and filter for export

This commit is contained in:
carla 2026-02-05 15:03:25 +01:00
parent e7d63b9b0a
commit 9b9e7ec995
10 changed files with 1013 additions and 714 deletions

View file

@ -0,0 +1,344 @@
defmodule Mv.Membership.MemberExport do
@moduledoc """
Builds member list and column specs for CSV export.
Used by `MvWeb.MemberExportController`. Does not perform translations;
the controller applies headers (e.g. via `MemberFields.label` / gettext)
and sends the download.
"""
require Ash.Query
import Ash.Expr
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MemberExportSort
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_status", "payment_status"]
@computed_export_fields ["membership_fee_status", "payment_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix()
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@doc """
Fetches members and column specs for export.
- `actor` - Ash actor (e.g. current user)
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
the controller adds `:header` and optional computed columns to members before CSV export.
"""
@spec fetch(struct(), map()) ::
{:ok, [struct()], [map()]} | {:error, :forbidden}
def fetch(actor, parsed) do
custom_field_ids_union =
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
column_specs = build_column_specs(parsed, custom_fields_by_id)
{:ok, members, column_specs}
end
end
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
query =
CustomField
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|> Ash.Query.select([:id, :name, :value_type])
case Ash.read(query, actor: actor) do
{:ok, custom_fields} ->
by_id =
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end)
{:ok, by_id}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_column_specs(parsed, custom_fields_by_id) do
member_specs =
Enum.map(parsed.member_fields, fn f ->
if f in parsed.selectable_member_fields do
%{kind: :member_field, key: f}
else
%{kind: :computed, key: String.to_existing_atom(f)}
end
end)
custom_specs =
parsed.custom_field_ids
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|> Enum.reject(&is_nil/1)
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
member_specs ++ custom_specs
end
defp load_members(actor, parsed, custom_fields_by_id) do
select_fields =
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
need_cycles =
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
parsed.computed_fields != []
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
query =
if parsed.selected_ids != [] do
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search(parsed.query)
|> then(fn q ->
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
q
end)
end
case Ash.read(query, actor: actor) do
{:ok, members} ->
members =
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
else
members
end
members =
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
sort_members_by_custom_field(
members,
parsed.sort_field,
parsed.sort_order,
Map.values(custom_fields_by_id)
)
else
members
end
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
cfv_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
Ash.Query.load(query, custom_field_values: cfv_query)
end
defp apply_search(query, nil), do: query
defp apply_search(query, ""), do: query
defp apply_search(query, q) when is_binary(q) do
if String.trim(q) != "" do
Member.fuzzy_search(query, %{query: q})
else
query
end
end
defp maybe_sort(query, nil, _order), do: {query, false}
defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
end
defp sort_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: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
key_fn = fn member ->
cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
end
members
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|> Enum.map(fn {m, _} -> m end)
end
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
(Map.get(cfv, :custom_field) &&
to_string(cfv.custom_field.id) == to_string(custom_field.id))
end)
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
end
defp apply_cycle_status_filter(members, _status, _show_current), do: members
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
@doc """
Parses and validates export params (from JSON payload).
Returns a map with :selected_ids, :member_fields, :selectable_member_fields,
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
:show_current_cycle, :cycle_status_filter, :boolean_filters.
"""
@spec parse_params(map()) :: map()
def parse_params(params) do
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: member_fields,
selectable_member_fields: selectable_member_fields,
computed_fields: computed_fields,
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
sort_order: extract_sort_order(params),
show_current_cycle: extract_boolean(params, "show_current_cycle"),
cycle_status_filter: extract_cycle_status_filter(params),
boolean_filters: extract_boolean_filters(params)
}
end
defp split_member_fields(member_fields) do
selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end)
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
{selectable, computed}
end
defp extract_boolean(params, key) do
case Map.get(params, key) do
true -> true
"true" -> true
_ -> false
end
end
defp extract_cycle_status_filter(params) do
case Map.get(params, "cycle_status_filter") do
"paid" -> :paid
"unpaid" -> :unpaid
_ -> nil
end
end
defp extract_boolean_filters(params) do
case Map.get(params, "boolean_filters") do
map when is_map(map) ->
map
|> Enum.filter(fn {k, v} -> is_binary(k) and is_boolean(v) end)
|> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end)
|> Enum.into(%{})
_ ->
%{}
end
end
defp extract_list(params, key) do
case Map.get(params, key) do
list when is_list(list) -> list
_ -> []
end
end
defp extract_string(params, key) do
case Map.get(params, key) do
s when is_binary(s) -> s
_ -> nil
end
end
defp extract_sort_order(params) do
case Map.get(params, "sort_order") do
"asc" -> "asc"
"desc" -> "desc"
_ -> nil
end
end
defp filter_allowed_member_fields(field_list) do
allowlist = MapSet.new(@member_fields_allowlist)
field_list
|> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
|> Enum.uniq()
end
defp filter_valid_uuids(id_list) when is_list(id_list) do
id_list
|> Enum.filter(fn id ->
is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
end)
|> Enum.uniq()
end
end

View file

@ -0,0 +1,44 @@
defmodule Mv.Membership.MemberExportSort do
@moduledoc """
Type-stable sort keys for CSV export custom-field sorting.
Used only by `MvWeb.MemberExportController` when sorting members by a custom field
after load. Nil values sort last in ascending order and first in descending order.
String and email comparison is case-insensitive.
"""
@doc """
Returns a comparable sort key for (value_type, value).
- Nil: rank 1 so that in asc order nil sorts last, in desc nil sorts first.
- date: chronological (ISO8601 string).
- boolean: false < true (0 < 1).
- integer: numerical order.
- string / email: case-insensitive (downcased).
Handles Ash.Union in value; value_type is the custom field's value_type atom.
"""
@spec custom_field_sort_key(:string | :integer | :boolean | :date | :email, term()) ::
{0 | 1, term()}
def custom_field_sort_key(_value_type, nil), do: {1, nil}
def custom_field_sort_key(value_type, %Ash.Union{value: value, type: _type}) do
custom_field_sort_key(value_type, value)
end
def custom_field_sort_key(:date, %Date{} = d), do: {0, Date.to_iso8601(d)}
def custom_field_sort_key(:boolean, true), do: {0, 1}
def custom_field_sort_key(:boolean, false), do: {0, 0}
def custom_field_sort_key(:integer, v) when is_integer(v), do: {0, v}
def custom_field_sort_key(:string, v) when is_binary(v), do: {0, String.downcase(v)}
def custom_field_sort_key(:email, v) when is_binary(v), do: {0, String.downcase(v)}
def custom_field_sort_key(_value_type, v), do: {0, to_string(v)}
@doc """
Returns true if key_a should sort before key_b for the given order.
"asc" -> nil last; "desc" -> nil first. No reverse of list needed.
"""
@spec key_lt({0 | 1, term()}, {0 | 1, term()}, String.t()) :: boolean()
def key_lt(key_a, key_b, "asc"), do: key_a < key_b
def key_lt(key_a, key_b, "desc"), do: key_b < key_a
end

View file

@ -2,9 +2,11 @@ defmodule Mv.Membership.MembersCSV do
@moduledoc """
Exports members to CSV (RFC 4180) as iodata.
Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings;
custom field values use the same formatting logic as the member overview (neutral formatter).
Column order for custom fields follows the key order of the `custom_fields_by_id` map.
Uses a column-based API: `export(members, columns)` where each column has
`header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed),
and `key` (member attribute name, custom_field id, or computed key). Custom field columns
include a `custom_field` struct for value formatting. Domain code does not use Gettext;
headers and computed values come from the caller (e.g. controller).
"""
alias Mv.Membership.CustomFieldValueFormatter
alias NimbleCSV.RFC4180
@ -12,57 +14,82 @@ defmodule Mv.Membership.MembersCSV do
@doc """
Exports a list of members to CSV iodata.
- `members` - List of member structs (with optional `custom_field_values` loaded)
- `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`)
- `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order.
- `members` - List of member structs or maps (with optional `custom_field_values` loaded)
- `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}`
For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller).
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
RFC 4180 escaping and formula-injection safe_cell are applied.
"""
@spec export(
[struct()],
[String.t()],
%{optional(String.t() | Ecto.UUID.t()) => struct()}
) :: iodata()
def export(members, member_fields, custom_fields_by_id) when is_list(members) do
custom_entries = custom_field_entries(custom_fields_by_id)
header = build_header(member_fields, custom_entries)
rows = Enum.map(members, &build_row(&1, member_fields, custom_entries))
@spec export([struct() | map()], [map()]) :: iodata()
def export(members, columns) when is_list(members) do
header = build_header(columns)
rows = Enum.map(members, fn member -> build_row(member, columns) end)
RFC4180.dump_to_iodata([header | rows])
end
defp custom_field_entries(by_id) when is_map(by_id) do
Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end)
defp build_header(columns) do
columns
|> Enum.map(fn col -> col.header end)
|> Enum.map(&safe_cell/1)
end
defp build_header(member_fields, custom_entries) do
member_headers = member_fields
custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end)
member_headers ++ custom_headers
defp build_row(member, columns) do
columns
|> Enum.map(fn col -> cell_value(member, col) end)
|> Enum.map(&safe_cell/1)
end
defp build_row(member, member_fields, custom_entries) do
member_cells = Enum.map(member_fields, &format_member_field(member, &1))
custom_cells =
Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end)
member_cells ++ custom_cells
end
defp format_member_field(member, field_name) do
key = member_field_key(field_name)
value = Map.get(member, key)
defp cell_value(member, %{kind: :member_field, key: key}) do
key_atom = key_to_atom(key)
value = Map.get(member, key_atom)
format_member_value(value)
end
defp member_field_key(field_name) when is_binary(field_name) do
defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}) do
cfv = get_cfv_by_id(member, id)
if cfv,
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf),
else: ""
end
defp cell_value(member, %{kind: :computed, key: key}) do
value = Map.get(member, key_to_atom(key))
if is_binary(value), do: value, else: ""
end
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
try do
String.to_existing_atom(field_name)
String.to_existing_atom(k)
rescue
ArgumentError -> field_name
ArgumentError -> k
end
end
defp get_cfv_by_id(member, id) do
values =
case Map.get(member, :custom_field_values) do
v when is_list(v) -> v
_ -> []
end
id_str = to_string(id)
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == id_str or
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
end)
end
@doc false
@spec safe_cell(String.t()) :: String.t()
def safe_cell(s) when is_binary(s) do
if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s
end
defp format_member_value(nil), do: ""
defp format_member_value(true), do: "true"
defp format_member_value(false), do: "false"
@ -70,22 +97,4 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_custom_field(member, custom_field_id, custom_field) do
cfv = find_custom_field_value(member, custom_field_id)
if cfv,
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field),
else: ""
end
defp find_custom_field_value(member, custom_field_id) do
values = Map.get(member, :custom_field_values) || []
id_str = to_string(custom_field_id)
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == id_str or
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
end)
end
end

View file

@ -178,7 +178,8 @@ defmodule MvWeb.CoreComponents do
aria-haspopup="menu"
aria-expanded={@open}
aria-controls={@id}
class="btn"
aria-label={@button_label}
class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid="dropdown-button"
@ -232,11 +233,12 @@ defmodule MvWeb.CoreComponents do
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
@ -247,7 +249,7 @@ defmodule MvWeb.CoreComponents do
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary"
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>

View file

@ -61,6 +61,7 @@ defmodule MvWeb.MemberExportController do
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
@ -68,6 +69,20 @@ defmodule MvWeb.MemberExportController do
}
end
defp filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(&is_binary/1)
|> Enum.filter(fn name ->
try do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
end
end)
|> Enum.uniq()
end
defp extract_list(params, key) do
case Map.get(params, key) do
list when is_list(list) -> list
@ -107,9 +122,16 @@ defmodule MvWeb.MemberExportController do
end
defp run_export(conn, actor, parsed) do
# FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
# auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
parsed =
parsed
|> ensure_sort_custom_field_loaded()
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
csv_iodata = MembersCSV.export(members, parsed.member_fields, custom_fields_by_id)
columns = build_columns(conn, parsed, custom_fields_by_id)
csv_iodata = MembersCSV.export(members, columns)
filename = "members-#{Date.utc_today()}.csv"
send_download(
@ -124,6 +146,26 @@ defmodule MvWeb.MemberExportController do
end
end
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
case extract_sort_custom_field_id(sort_field) do
nil ->
parsed
id ->
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
end
end
defp extract_sort_custom_field_id(field) when is_binary(field) do
if String.starts_with?(field, @custom_field_prefix) do
String.trim_leading(field, @custom_field_prefix)
else
nil
end
end
defp extract_sort_custom_field_id(_), do: nil
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
@ -148,17 +190,13 @@ defmodule MvWeb.MemberExportController do
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
find_and_add_custom_field(acc, id, custom_fields)
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
@ -170,20 +208,20 @@ defmodule MvWeb.MemberExportController do
query =
if parsed.selected_ids != [] do
# selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search_export(parsed.query)
|> then(fn q ->
{q, _sort_after_load} = maybe_sort_export(q, parsed.sort_field, parsed.sort_order)
q
end)
end
# FIX: Sortierung IMMER anwenden (auch bei selected_ids)
{query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
case Ash.read(query, actor: actor) do
{:ok, members} ->
members =
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
if sort_after_load do
sort_members_by_custom_field_export(
members,
parsed.sort_field,
@ -191,7 +229,6 @@ defmodule MvWeb.MemberExportController do
Map.values(custom_fields_by_id)
)
else
# selected_ids != []: no sort. selected_ids == [] and DB sort: already in query.
members
end
@ -228,25 +265,29 @@ defmodule MvWeb.MemberExportController do
defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
cond do
custom_field_sort?(field) ->
# Custom field sort → in-memory nach dem Read (wie Tabelle)
{query, true}
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
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
rescue
ArgumentError -> {query, false}
end
defp sort_after_load?(field) when is_binary(field),
do: String.starts_with?(field, @custom_field_prefix)
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
defp sort_after_load?(_), do: false
# ------------------------------------------------------------------
# Custom field sorting (match member table behavior)
# ------------------------------------------------------------------
defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
when members == [],
@ -254,26 +295,60 @@ defmodule MvWeb.MemberExportController do
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
when is_binary(field) do
order = order || "asc"
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
extract_sort_val = fn member ->
cfv = find_cfv(member, custom_field)
if cfv, do: extract_sort_value(cfv.value, custom_field.value_type), else: nil
end
custom_field =
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
sorted =
if is_nil(custom_field) do
members
|> Enum.sort_by(extract_sort_val, fn
nil, _ -> false
_, nil -> true
a, b -> if order == "desc", do: a >= b, else: a <= b
end)
else
# Match table:
# 1) values first, empty last
# 2) sort only values
# 3) for desc, reverse only the values-part
{with_values, without_values} =
Enum.split_with(members, fn member ->
has_non_empty_custom_field_value?(member, custom_field)
end)
if order == "desc", do: Enum.reverse(sorted), else: sorted
sorted_with_values =
Enum.sort_by(with_values, fn member ->
member
|> find_cfv(custom_field)
|> case do
nil -> nil
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
end
end)
sorted_with_values =
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
sorted_with_values ++ without_values
end
end
defp has_non_empty_custom_field_value?(member, custom_field) do
case find_cfv(member, custom_field) do
nil ->
false
cfv ->
extracted = extract_sort_value(cfv.value, custom_field.value_type)
not empty_value?(extracted, custom_field.value_type)
end
end
defp empty_value?(nil, _type), do: true
defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
String.trim(value) == ""
end
defp empty_value?(_value, _type), do: false
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
@ -283,15 +358,76 @@ defmodule MvWeb.MemberExportController do
end)
end
defp build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
Enum.map(parsed.member_fields, fn field ->
%{
header: member_field_header(conn, field),
kind: :member_field,
key: field
}
end)
computed_cols =
Enum.map(parsed.computed_fields, fn key ->
%{
header: computed_field_header(conn, key),
kind: :computed,
key: key
}
end)
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
if cf do
%{
header: custom_field_header(conn, cf),
kind: :custom_field,
key: to_string(id),
custom_field: cf
}
else
nil
end
end)
|> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols
end
# --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
defp member_field_header(_conn, field) when is_binary(field) do
# TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
humanize_field(field)
end
defp computed_field_header(_conn, key) when is_binary(key) do
# TODO: display-name helper für computed fields verwenden
humanize_field(key)
end
defp custom_field_header(_conn, cf) do
# Custom fields: meist ist cf.name bereits der Display Name
cf.name
end
defp humanize_field(str) do
str
|> String.replace("_", " ")
|> String.capitalize()
end
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
do: extract_sort_value(value, type)
defp extract_sort_value(nil, _), do: nil
defp extract_sort_value(value, :string) when is_binary(value), do: value
defp extract_sort_value(value, :integer) when is_integer(value), do: value
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
defp extract_sort_value(%Date{} = d, :date), do: d
defp extract_sort_value(value, :email) when is_binary(value), do: value
defp extract_sort_value(value, _), do: to_string(value)
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
end

View file

@ -41,24 +41,29 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
# RENDER
# ---------------------------------------------------------------------------
# Export-only alias; must not appear in dropdown (canonical UI key is membership_fee_status).
@payment_status_value "payment_status"
@impl true
def render(assigns) do
all_fields = assigns.all_fields || []
custom_fields = assigns.custom_fields || []
all_items =
Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end)
(Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end))
|> Enum.reject(fn item -> item.value == @payment_status_value end)
|> Enum.uniq_by(fn item -> item.value end)
assigns = assign(assigns, :all_items, all_items)

File diff suppressed because it is too large Load diff

View file

@ -293,6 +293,7 @@
</:col>
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(

View file

@ -18,10 +18,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
## Pseudo Member Fields
Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
"""
alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
@pseudo_member_fields [:membership_fee_status]
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields
end
@doc """
Gets all available fields for selection.
@ -39,7 +54,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields = Mv.Constants.member_fields()
member_fields =
overview_member_fields()
|> Enum.reject(fn field -> field == @export_only_alias end)
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_fields(_), do: []
@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = Mv.Constants.member_fields()
member_fields = overview_member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields(_), do: []
@doc """
Returns the list of computed (UI-only) member field atoms.
These fields are not in the database; they must not be used for Ash query
select/sort. Use this to filter sort options and validate sort_field.
"""
@spec computed_member_fields() :: [atom()]
def computed_member_fields, do: @pseudo_member_fields
@doc """
Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
Use for query select/sort. Not for rendering column visibility (use
`get_visible_member_fields/1` for that).
"""
@spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
db_fields = MapSet.new(Mv.Constants.member_fields())
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in db_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_db(_), do: []
@doc """
Visible member fields that are computed/UI-only (e.g. membership_fee_status).
Use for rendering; do not use for query select or sort.
"""
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new(@pseudo_member_fields)
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in computed_set
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_computed(_), do: []
@doc """
Gets visible custom fields from field selection.
@ -176,19 +246,23 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
domain_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
# exit_date defaults to false (hidden), all other fields default to true
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
end)
end
@ -203,16 +277,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
# Converts field string to atom (for member fields) or keeps as string (for custom fields).
# Maps export-only alias to canonical UI key so only one option controls the column.
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
atom =
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
if atom == @export_only_alias, do: :membership_fee_status, else: atom
end
end

View file

@ -28,6 +28,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:house_number), do: gettext("House Number")
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")
# Fallback for unknown fields
def label(field) do