feat: add membership fee status to columns and dropdown

This commit is contained in:
carla 2026-02-09 13:34:38 +01:00
parent 36e57b24be
commit e1266944b1
7 changed files with 725 additions and 514 deletions

View file

@ -0,0 +1,170 @@
defmodule Mv.Membership.Import.ImportRunner do
@moduledoc """
Orchestrates CSV member import: file reading, progress tracking, chunk processing,
and error formatting. Used by `MvWeb.ImportExportLive` to keep LiveView thin.
This module does not depend on Phoenix or LiveView. It provides pure functions for
progress/merge and side-effectful helpers (read_file_entry, process_chunk) that
are called from the LiveView or from tasks started by it.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Import.MemberCSV
@default_max_errors 50
@doc """
Reads file content from a Phoenix LiveView upload entry (path).
Used as the callback for `consume_uploaded_entries/3`. Returns `{:ok, content}` or
`{:error, reason}` with a user-friendly string.
"""
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
def read_file_entry(%{path: path}, _entry) do
case File.read(path) do
{:ok, content} ->
{:ok, content}
{:error, reason} when is_atom(reason) ->
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
{:error, :file.format_error(reason)}
{:error, reason} ->
{:error, Exception.message(reason)}
end
end
@doc """
Normalizes the result of `consume_uploaded_entries/3` into `{:ok, content}` or `{:error, reason}`.
Handles both the standard `[{:ok, content}]` and test helpers that may return `[content]`.
"""
@spec parse_consume_result(list()) :: {:ok, String.t()} | {:error, String.t()}
def parse_consume_result(raw) do
case raw do
[{:ok, content}] when is_binary(content) -> {:ok, content}
[content] when is_binary(content) -> {:ok, content}
[{:error, reason}] -> {:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] -> {:error, gettext("No file was uploaded")}
_other -> {:error, gettext("Failed to read uploaded file: unexpected format")}
end
end
@doc """
Builds the initial progress map from a prepared import_state.
"""
@spec initial_progress(map(), keyword()) :: map()
def initial_progress(import_state, opts \\ []) do
_max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
total = length(import_state.chunks)
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: total,
errors_truncated?: false
}
end
@doc """
Merges a chunk result into the current progress and returns updated progress.
Caps errors at `max_errors` (default 50). Sets `status` to `:done` when all chunks
have been processed.
"""
@spec merge_progress(map(), map(), non_neg_integer(), keyword()) :: map()
def merge_progress(progress, chunk_result, current_chunk_idx, opts \\ []) do
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, max_errors)
errors_truncated? = length(all_errors) > max_errors
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || Map.get(chunk_result, :errors_truncated?, false)
}
end
@doc """
Returns the next action after processing a chunk: send the next chunk index or done.
"""
@spec next_chunk_action(non_neg_integer(), non_neg_integer()) ::
{:send_chunk, non_neg_integer()} | :done
def next_chunk_action(current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks, do: {:send_chunk, next_idx}, else: :done
end
@doc """
Processes one chunk (validate + create members), then sends `{:chunk_done, idx, result}`
or `{:chunk_error, idx, reason}` to `live_view_pid`.
Options: `:custom_field_lookup`, `:existing_error_count`, `:max_errors`, `:actor`.
"""
@spec process_chunk(
list(),
map(),
map(),
keyword(),
pid(),
non_neg_integer()
) :: :ok
def process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx) do
result =
try do
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
rescue
e -> {:error, Exception.message(e)}
catch
:exit, reason -> {:error, inspect(reason)}
:throw, reason -> {:error, inspect(reason)}
end
case result do
{:ok, chunk_result} -> send(live_view_pid, {:chunk_done, idx, chunk_result})
{:error, reason} -> send(live_view_pid, {:chunk_error, idx, reason})
end
:ok
end
@doc """
Returns a user-facing error message for chunk failures (invalid index, missing state,
or processing failure).
"""
@spec format_chunk_error(
:invalid_index | :missing_state | :processing_failed,
non_neg_integer(),
any()
) ::
String.t()
def format_chunk_error(:invalid_index, idx, _reason) do
gettext("Invalid chunk index: %{idx}", idx: idx)
end
def format_chunk_error(:missing_state, idx, _reason) do
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
end
def format_chunk_error(:processing_failed, idx, reason) do
gettext("Failed to process chunk %{idx}: %{reason}", idx: idx, reason: inspect(reason))
end
end

View file

@ -16,8 +16,9 @@ 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", "payment_status"]
@computed_export_fields ["membership_fee_status", "payment_status"]
["membership_fee_status"]
@computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@ -75,9 +76,16 @@ defmodule Mv.Membership.MemberExport do
if f in parsed.selectable_member_fields do
%{kind: :member_field, key: f}
else
# only allow known computed export fields to avoid crashing on unknown atoms
if f in @computed_export_fields do
%{kind: :computed, key: String.to_existing_atom(f)}
else
# ignore unknown non-selectable fields defensively
nil
end
end
end)
|> Enum.reject(&is_nil/1)
custom_specs =
parsed.custom_field_ids
@ -96,7 +104,8 @@ defmodule Mv.Membership.MemberExport do
need_cycles =
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
parsed.computed_fields != []
parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields
query =
Member
@ -143,6 +152,9 @@ defmodule Mv.Membership.MemberExport do
members
end
# Calculate membership_fee_status for computed fields
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
@ -241,6 +253,19 @@ defmodule Mv.Membership.MemberExport do
defp apply_cycle_status_filter(members, _status, _show_current), do: members
defp add_computed_fields(members, computed_fields, show_current_cycle) do
computed_fields = computed_fields || []
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
Map.put(member, :membership_fee_status, status) # <= Atom rein
end)
else
members
end
end
# 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).
@ -251,12 +276,31 @@ defmodule Mv.Membership.MemberExport do
"""
@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)
# DB fields come from "member_fields"
raw_member_fields = extract_list(params, "member_fields")
member_fields = filter_allowed_member_fields(raw_member_fields)
# computed fields can come from "computed_fields" (new payload) OR legacy inclusion in member_fields
computed_fields =
(extract_list(params, "computed_fields") ++ member_fields)
|> normalize_computed_fields()
|> Enum.filter(&(&1 in @computed_export_fields))
|> Enum.uniq()
# selectable DB fields: only real domain member fields, ordered like the table
selectable_member_fields =
member_fields
|> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table()
# final member_fields list (used for column specs order): table order + computed inserted
ordered_member_fields =
selectable_member_fields
|> insert_computed_fields_like_table(computed_fields)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: member_fields,
member_fields: ordered_member_fields,
selectable_member_fields: selectable_member_fields,
computed_fields: computed_fields,
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
@ -269,12 +313,6 @@ defmodule Mv.Membership.MemberExport do
}
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
@ -341,4 +379,41 @@ defmodule Mv.Membership.MemberExport do
end)
|> Enum.uniq()
end
defp order_member_fields_like_table(fields) when is_list(fields) do
table_order = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
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.
computed_fields = computed_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
end)
remaining =
computed_fields
|> Enum.reject(&(&1 in db_with_insert))
db_with_insert ++ remaining
end
defp normalize_computed_fields(fields) when is_list(fields) do
fields
|> Enum.filter(&is_binary/1)
|> Enum.map(fn
"payment_status" -> "membership_fee_status"
other -> other
end)
end
defp normalize_computed_fields(_), do: []
end

View file

@ -14,8 +14,12 @@ defmodule MvWeb.MemberExportController do
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MembersCSV
alias MvWeb.Translations.MemberFields
alias MvWeb.MemberLive.Index.MembershipFeeStatus
use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do
@ -58,17 +62,38 @@ defmodule MvWeb.MemberExportController do
end
defp parse_and_validate(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: filter_allowed_member_fields(extract_list(params, "member_fields")),
computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
member_fields: member_fields,
selectable_member_fields: selectable_member_fields,
computed_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"),
sort_order: extract_sort_order(params)
sort_order: extract_sort_order(params),
show_current_cycle: extract_boolean(params, "show_current_cycle")
}
end
defp split_member_fields(member_fields) do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields 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 filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(&is_binary/1)
@ -198,13 +223,17 @@ defmodule MvWeb.MemberExportController do
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)
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
need_cycles =
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
query =
if parsed.selected_ids != [] do
@ -232,6 +261,9 @@ defmodule MvWeb.MemberExportController do
members
end
# Calculate membership_fee_status for computed fields
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
@ -239,6 +271,31 @@ defmodule MvWeb.MemberExportController do
end
end
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
# 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
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
status_string = format_membership_fee_status(status)
Map.put(member, :membership_fee_status, status_string)
end)
else
members
end
end
# Formats membership fee status as German string
defp format_membership_fee_status(:paid), do: gettext("paid")
defp format_membership_fee_status(:unpaid), do: gettext("unpaid")
defp format_membership_fee_status(:suspended), do: gettext("suspended")
defp format_membership_fee_status(nil), do: ""
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
@ -360,7 +417,7 @@ defmodule MvWeb.MemberExportController do
defp build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
Enum.map(parsed.member_fields, fn field ->
Enum.map(parsed.selectable_member_fields, fn field ->
%{
header: member_field_header(conn, field),
kind: :member_field,
@ -373,7 +430,7 @@ defmodule MvWeb.MemberExportController do
%{
header: computed_field_header(conn, key),
kind: :computed,
key: key
key: String.to_existing_atom(key)
}
end)
@ -398,14 +455,35 @@ defmodule MvWeb.MemberExportController do
member_cols ++ computed_cols ++ custom_cols
end
# --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
# --- headers: use MemberFields.label for translations ---
defp member_field_header(_conn, field) when is_binary(field) do
# TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
field
|> String.to_existing_atom()
|> MemberFields.label()
rescue
ArgumentError ->
# Fallback for unknown fields
humanize_field(field)
end
defp computed_field_header(_conn, key) when is_atom(key) do
# Map export-only alias to canonical UI key for translation
atom_key = if key == :payment_status, do: :membership_fee_status, else: key
MemberFields.label(atom_key)
end
defp computed_field_header(_conn, key) when is_binary(key) do
# TODO: display-name helper für computed fields verwenden
# Map export-only alias to canonical UI key for translation
atom_key =
case key do
"payment_status" -> :membership_fee_status
_ -> String.to_existing_atom(key)
end
MemberFields.label(atom_key)
rescue
ArgumentError ->
# Fallback for unknown computed fields
humanize_field(key)
end
@ -417,7 +495,8 @@ defmodule MvWeb.MemberExportController do
defp humanize_field(str) do
str
|> String.replace("_", " ")
|> String.capitalize()
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),

View file

@ -41,9 +41,6 @@ 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 || []
@ -62,7 +59,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
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)

View file

@ -35,8 +35,10 @@ defmodule MvWeb.ImportExportLive do
alias Mv.Authorization.Actor
alias Mv.Config
alias Mv.Membership
alias Mv.Membership.Import.ImportRunner
alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization
alias MvWeb.ImportExportLive.Components
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -98,11 +100,11 @@ defmodule MvWeb.ImportExportLive do
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<%!-- CSV Import Section --%>
<.form_section title={gettext("Import Members (CSV)")}>
{import_info_box(assigns)}
{template_links(assigns)}
{import_form(assigns)}
<%= if @import_status == :running or @import_status == :done do %>
{import_progress(assigns)}
<Components.custom_fields_notice {assigns} />
<Components.template_links {assigns} />
<Components.import_form {assigns} />
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
<Components.import_progress {assigns} />
<% end %>
</.form_section>
@ -129,223 +131,6 @@ defmodule MvWeb.ImportExportLive do
"""
end
# Renders the info box explaining CSV import requirements
defp import_info_box(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
)}
</p>
<p class="text-sm">
<.link
href={~p"/settings#custom_fields"}
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Member Data")}
</.link>
</p>
</div>
</div>
"""
end
# Renders template download links
defp template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
"""
end
# Renders the CSV upload form
defp import_form(assigns) do
~H"""
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
"""
end
# Renders import progress and results
defp import_progress(assigns) do
~H"""
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done do %>
{import_results(assigns)}
<% end %>
</div>
<% end %>
"""
end
# Renders import results summary, errors, and warnings
defp import_results(assigns) do
~H"""
<section class="space-y-4" data-testid="import-results-panel">
<h2 class="text-lg font-semibold">
{gettext("Import Results")}
</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div data-testid="import-error-list">
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning" role="alert">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
"""
end
@impl true
def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket}
@ -436,7 +221,7 @@ defmodule MvWeb.ImportExportLive do
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp start_import(socket, import_state) do
progress = initialize_import_progress(import_state)
progress = ImportRunner.initial_progress(import_state, max_errors: @max_errors)
socket =
socket
@ -449,21 +234,6 @@ defmodule MvWeb.ImportExportLive do
{:noreply, socket}
end
# Initializes the import progress tracking structure with default values.
@spec initialize_import_progress(map()) :: map()
defp initialize_import_progress(import_state) do
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: length(import_state.chunks),
errors_truncated?: false
}
end
# Formats error messages for user-friendly display.
#
# Handles various error types including Ash errors, maps with message fields,
@ -557,52 +327,8 @@ defmodule MvWeb.ImportExportLive do
handle_chunk_error(socket, :processing_failed, idx, reason)
end
# Processes a chunk with error handling and sends result message to LiveView.
#
# Handles errors from MemberCSV.process_chunk and sends appropriate messages
# to the LiveView process for progress tracking.
@spec process_chunk_with_error_handling(
list(),
map(),
map(),
keyword(),
pid(),
non_neg_integer()
) :: :ok
defp process_chunk_with_error_handling(
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx
) do
result =
try do
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
rescue
e ->
{:error, Exception.message(e)}
catch
:exit, reason ->
{:error, inspect(reason)}
:throw, reason ->
{:error, inspect(reason)}
end
case result do
{:ok, chunk_result} ->
send(live_view_pid, {:chunk_done, idx, chunk_result})
{:error, reason} ->
send(live_view_pid, {:chunk_error, idx, reason})
end
end
# Starts async task to process a chunk of CSV rows.
#
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues.
# Starts async task to process a chunk of CSV rows (or runs synchronously in test sandbox).
# Locale must be set in the process that runs the chunk (Gettext is process-local); see run_chunk_with_locale/7.
@spec start_chunk_processing_task(
Phoenix.LiveView.Socket.t(),
map(),
@ -613,8 +339,8 @@ defmodule MvWeb.ImportExportLive do
chunk = Enum.at(import_state.chunks, idx)
actor = ensure_actor_loaded(socket)
live_view_pid = self()
locale = socket.assigns[:locale] || "de"
# Process chunk with existing error count for capping
opts = [
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
@ -622,15 +348,9 @@ defmodule MvWeb.ImportExportLive do
actor: actor
]
# Get locale from socket for translations in background tasks
locale = socket.assigns[:locale] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
if Config.sql_sandbox?() do
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
# In test mode, send the message - it will be processed when render() is called
# in the test. The test helper wait_for_import_completion() handles message processing
process_chunk_with_error_handling(
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
@ -639,49 +359,38 @@ defmodule MvWeb.ImportExportLive do
idx
)
else
# Start async task to process chunk in production
# Use start_child for fire-and-forget: no monitor, no Task messages
# We only use our own send/2 messages for communication
Task.Supervisor.start_child(
Mv.TaskSupervisor,
build_chunk_processing_task(
fn ->
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx,
locale
idx
)
end
)
end
{:noreply, socket}
end
# Builds the task function for processing a chunk asynchronously.
defp build_chunk_processing_task(
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx,
locale
) do
fn ->
# Set locale in task process for translations
Gettext.put_locale(MvWeb.Gettext, locale)
process_chunk_with_error_handling(
# Sets Gettext locale in the current process, then processes the chunk.
# Must be called in the process that runs the chunk (sync: LiveView process; async: Task process).
defp run_chunk_with_locale(
locale,
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx
)
end
) do
Gettext.put_locale(MvWeb.Gettext, locale)
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
end
# Handles chunk processing result from async task and schedules the next chunk.
@ -693,20 +402,29 @@ defmodule MvWeb.ImportExportLive do
map()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
# Merge progress
new_progress = merge_progress(progress, chunk_result, idx)
new_progress =
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
socket =
socket
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
# Schedule next chunk or mark as done
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
|> maybe_send_next_chunk(idx, length(import_state.chunks))
{:noreply, socket}
end
defp maybe_send_next_chunk(socket, current_idx, total_chunks) do
case ImportRunner.next_chunk_action(current_idx, total_chunks) do
{:send_chunk, next_idx} ->
send(self(), {:process_chunk, next_idx})
socket
:done ->
socket
end
end
# Handles chunk processing errors and updates socket with error status.
@spec handle_chunk_error(
Phoenix.LiveView.Socket.t(),
@ -715,129 +433,23 @@ defmodule MvWeb.ImportExportLive do
any()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
error_message =
case error_type do
:invalid_index ->
gettext("Invalid chunk index: %{idx}", idx: idx)
:missing_state ->
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
:processing_failed ->
gettext("Failed to process chunk %{idx}: %{reason}",
idx: idx,
reason: inspect(reason)
)
end
message = ImportRunner.format_chunk_error(error_type, idx, reason)
socket =
socket
|> assign(:import_status, :error)
|> put_flash(:error, error_message)
|> put_flash(:error, message)
{:noreply, socket}
end
# Consumes uploaded CSV file entries and reads the file content.
#
# Returns the file content as a binary string or an error tuple.
@spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) ::
{:ok, String.t()} | {:error, String.t()}
defp consume_and_read_csv(socket) do
raw = consume_uploaded_entries(socket, :csv_file, &read_file_entry/2)
case raw do
[{:ok, content}] when is_binary(content) ->
{:ok, content}
# Phoenix LiveView test (render_upload) can return raw content list when callback return is treated as value
[content] when is_binary(content) ->
{:ok, content}
[{:error, reason}] ->
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] ->
{:error, gettext("No file was uploaded")}
_other ->
{:error, gettext("Failed to read uploaded file: unexpected format")}
raw = consume_uploaded_entries(socket, :csv_file, &ImportRunner.read_file_entry/2)
ImportRunner.parse_consume_result(raw)
end
end
# Reads a single file entry from the uploaded path
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
defp read_file_entry(%{path: path}, _entry) do
case File.read(path) do
{:ok, content} ->
{:ok, content}
{:error, reason} when is_atom(reason) ->
# POSIX error atoms (e.g., :enoent) need to be formatted
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
# File.Error struct with reason atom
{:error, :file.format_error(reason)}
{:error, reason} ->
# Fallback for other error types
{:error, Exception.message(reason)}
end
end
# Merges chunk processing results into the overall import progress.
#
# Handles error capping, warning merging, and status updates.
@spec merge_progress(map(), map(), non_neg_integer()) :: map()
defp merge_progress(progress, chunk_result, current_chunk_idx) do
# Merge errors with cap of @max_errors overall
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, @max_errors)
errors_truncated? = length(all_errors) > @max_errors
# Merge warnings (optional dedupe - simple append for now)
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
# Update status based on whether we're done
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
}
end
# Schedules the next chunk for processing or marks import as complete.
@spec schedule_next_chunk(Phoenix.LiveView.Socket.t(), non_neg_integer(), non_neg_integer()) ::
Phoenix.LiveView.Socket.t()
defp schedule_next_chunk(socket, current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks do
# Schedule next chunk
send(self(), {:process_chunk, next_idx})
socket
else
# All chunks processed - status already set to :done in merge_progress
socket
end
end
# Determines if the import button should be disabled based on import status and upload state
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
defp import_button_disabled?(:running, _entries), do: true
defp import_button_disabled?(_status, []), do: true
defp import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
defp import_button_disabled?(_status, _entries), do: false
# Ensures the actor (user with role) is loaded from socket assigns.
#

View file

@ -0,0 +1,272 @@
defmodule MvWeb.ImportExportLive.Components do
@moduledoc """
Function components for the Import/Export LiveView: import form, progress, results,
custom fields notice, and template links. Keeps the main LiveView focused on
mount/handle_event/handle_info and glue code.
"""
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
import MvWeb.CoreComponents
use Phoenix.VerifiedRoutes,
endpoint: MvWeb.Endpoint,
router: MvWeb.Router,
statics: MvWeb.static_paths()
@doc """
Renders the info box explaining that data fields must exist before import
and linking to Manage Member Data (custom fields).
"""
def custom_fields_notice(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
)}
</p>
<p class="text-sm">
<.link
href={~p"/settings#custom_fields"}
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Member Data")}
</.link>
</p>
</div>
</div>
"""
end
@doc """
Renders download links for English and German CSV templates.
"""
def template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
"""
end
@doc """
Renders the CSV file upload form and Start Import button.
"""
def import_form(assigns) do
~H"""
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
"""
end
@doc """
Renders import progress text and, when done or aborted, the import results section.
"""
def import_progress(assigns) do
~H"""
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done or @import_status == :error do %>
<.import_results {assigns} />
<% end %>
</div>
<% end %>
"""
end
@doc """
Renders import results summary, error list, and warnings.
Shown when import is done or aborted (:error); heading reflects state.
"""
def import_results(assigns) do
~H"""
<section
class="space-y-4"
data-testid="import-results-panel"
aria-labelledby="import-results-heading"
>
<h2
id="import-results-heading"
class="text-lg font-semibold"
data-testid="import-results-heading"
>
<%= if @import_status == :error do %>
{gettext("Import aborted")}
<% else %>
{gettext("Import Results")}
<% end %>
</h2>
<div class="space-y-4">
<div data-testid="import-summary">
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
data-testid="import-error-list"
>
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning" role="alert" data-testid="import-warnings">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
"""
end
@doc """
Returns whether the Start Import button should be disabled.
"""
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
def import_button_disabled?(:running, _entries), do: true
def import_button_disabled?(_status, []), do: true
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
def import_button_disabled?(_status, _entries), do: false
end

View file

@ -100,7 +100,6 @@ defmodule MvWeb.MemberLive.Index do
all_available_fields =
all_custom_fields
|> FieldVisibility.get_all_available_fields()
|> dedupe_available_fields()
initial_selection =
FieldVisibility.merge_with_global_settings(
@ -124,6 +123,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:boolean_custom_fields, boolean_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(:fields_in_url?, false)
|> assign(
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
@ -245,6 +245,7 @@ defmodule MvWeb.MemberLive.Index do
new_show_current,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
new_path = ~p"/members?#{query_params}"
@ -351,7 +352,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
@ -373,6 +374,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
new_path = ~p"/members?#{query_params}"
@ -396,6 +398,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
@ -425,6 +428,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
updated_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
@ -448,6 +452,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
boolean_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
@ -537,6 +542,12 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_params(params, _url, socket) do
prev_sig = build_signature(socket)
fields_in_url? =
case Map.get(params, "fields") do
v when is_binary(v) and v != "" -> true
_ -> false
end
url_selection = FieldSelection.parse_from_url(params)
merged_selection =
@ -572,6 +583,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_cycle_status_filter(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
@ -674,37 +686,18 @@ defmodule MvWeb.MemberLive.Index do
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
defp push_sort_url(socket, field, order) do
field_str =
if is_atom(field) do
Atom.to_string(field)
else
field
end
query_params =
build_query_params(
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
defp maybe_add_field_selection(params, nil), do: params
defp maybe_add_field_selection(params, selection) when is_map(selection) do
# Only keep `fields` in the URL when it was already present (bookmark/share),
# OR when we intentionally push it via push_field_selection_url/1.
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
cond do
fields_param == "" -> Map.delete(params, "fields")
true -> Map.put(params, "fields", fields_param)
end
end
defp maybe_add_field_selection(params, _), do: params
defp maybe_add_field_selection(params, _selection, _include?), do: params
defp push_field_selection_url(socket) do
query_params =
@ -716,7 +709,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
@ -1398,6 +1391,12 @@ defmodule MvWeb.MemberLive.Index do
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids,
column_order:
export_column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids
),
query: socket.assigns[:query] || nil,
sort_field: export_sort_field(socket.assigns[:sort_field]),
sort_order: export_sort_order(socket.assigns[:sort_order]),
@ -1420,25 +1419,33 @@ defmodule MvWeb.MemberLive.Index do
defp export_sort_order(:asc), do: "asc"
defp export_sort_order(:desc), do: "desc"
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)
# - 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
) do
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
# -------------------------------------------------------------
# Internal utility: dedupe dropdown fields defensively
# -------------------------------------------------------------
defp dedupe_available_fields(fields) when is_list(fields) do
Enum.uniq_by(fields, fn item ->
cond do
is_map(item) ->
Map.get(item, :key) || Map.get(item, :id) || Map.get(item, :field) || item
is_tuple(item) and tuple_size(item) >= 1 ->
elem(item, 0)
true ->
item
# 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)
end
defp dedupe_available_fields(other), do: other
# Any remaining computed fields not inserted above (future-proof)
remaining_computed =
computed_strings
|> Enum.reject(&(&1 in db_with_computed))
db_with_computed ++ remaining_computed ++ ordered_custom_field_ids
end
end