defmodule MvWeb.ImportLive.Components do @moduledoc """ Function components for the Import 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"""
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />

{gettext( "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." )}

{gettext( "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." )}

{gettext( "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." )}

{gettext( "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." )}

""" end @doc """ Renders download links for English and German CSV templates. """ def template_links(assigns) do ~H"""

{gettext("Download CSV templates:")}

""" 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" >

{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}

<.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")} """ end @doc """ Renders the mapping preview shown between upload and processing. Shows the column-to-role mapping, up to 3 sample rows, and notices for auto-created groups, unresolved fee types, empty fee-type cells, and unknown columns. Nothing is written until the user confirms. """ def preview(assigns) do state = assigns.import_state column_roles = column_roles(state) column_samples = column_samples(state.preview_rows, length(state.headers)) assigns = assigns |> assign(:column_roles, column_roles) |> assign(:column_samples, column_samples) ~H"""

{gettext("Preview import")}

<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %> <%= for sample <- samples do %> <% end %> <% end %>
{gettext("Role")} {gettext("Column")} {gettext("Row 1")} {gettext("Row 2")} {gettext("Row 3")}
{role_label(role)} {header}{sample}
<%= if @import_state.groups_to_create != [] do %>
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />

{gettext("These groups will be created automatically: %{names}", names: Enum.join(@import_state.groups_to_create, ", ") )}

<% end %> <%= if @import_state.fee_type_warnings != [] do %> <% end %> <%= if @import_state.has_empty_fee_type_cells? do %>
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />

{gettext("Rows with an empty fee type will get the default fee type.")}

<% end %> <%= if @import_state.warnings != [] do %> <% end %>
<.button type="button" phx-click="confirm_import" variant="primary" data-testid="confirm-import-button" > {gettext("Confirm and Import")} <.button type="button" phx-click="cancel_import" data-testid="cancel-import-button"> {gettext("Cancel")}
""" end # Pairs each CSV header with its resolved role for the preview mapping table. defp column_roles(state) do member_indices = MapSet.new(Map.values(state.column_map)) custom_indices = MapSet.new(Map.values(state.custom_field_map)) ignored_headers = MapSet.new(state.ignored) state.headers |> Enum.with_index() |> Enum.map(fn {header, index} -> {header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)} end) end defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do cond do index == state.groups_column_index -> :groups index == state.fee_type_column_index -> :fee_type MapSet.member?(ignored_headers, header) -> :ignored MapSet.member?(member_indices, index) -> :member_field MapSet.member?(custom_indices, index) -> :custom_field true -> :unknown end end defp role_label(:member_field), do: gettext("Member field") defp role_label(:custom_field), do: gettext("Custom field") defp role_label(:groups), do: gettext("Groups") defp role_label(:fee_type), do: gettext("Fee type") defp role_label(:ignored), do: gettext("Ignored (system-computed field)") defp role_label(:unknown), do: gettext("Unknown (ignored)") defp role_badge_class(:member_field), do: "badge-primary" defp role_badge_class(:custom_field), do: "badge-secondary" defp role_badge_class(:groups), do: "badge-success" defp role_badge_class(:fee_type), do: "badge-warning" defp role_badge_class(:ignored), do: "badge-ghost" defp role_badge_class(:unknown), do: "badge-error" defp role_row_class(:ignored), do: "opacity-50" defp role_row_class(:unknown), do: "opacity-50" defp role_row_class(_), do: nil defp column_samples([], col_count), do: List.duplicate([], col_count) defp column_samples(rows, col_count) do Enum.map(0..(col_count - 1), fn col_idx -> rows |> Enum.map(fn row -> Enum.at(row, col_idx, "") end) |> pad_to(3, "") end) end defp pad_to(list, target, fill) do list ++ List.duplicate(fill, max(0, target - length(list))) 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 %>
<%= if @import_progress.status == :running do %>

{gettext("Processing chunk %{current} of %{total}...", current: @import_progress.current_chunk, total: @import_progress.total_chunks )}

<% end %> <%= if @import_progress.status == :done or @import_status == :error do %> <.import_results {assigns} /> <% end %>
<% 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"""

<%= if @import_status == :error do %> {gettext("Import aborted")} <% else %> {gettext("Import Results")} <% end %>

{gettext("Summary")}

<.icon name="hero-check-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Successfully inserted: %{count} member(s)", count: @import_progress.inserted )}

<%= if @import_progress.failed > 0 do %>

<.icon name="hero-exclamation-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Failed: %{count} row(s)", count: @import_progress.failed)}

<% end %> <%= if @import_progress.errors_truncated? do %>

<.icon name="hero-information-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Error list truncated to %{count} entries", count: @max_errors)}

<% end %>
<%= if length(@import_progress.errors) > 0 do %>

<.icon name="hero-exclamation-circle" class="size-4 inline mr-1" aria-hidden="true" /> {gettext("Errors")}

    <%= for error <- @import_progress.errors do %>
  • {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 %>
  • <% end %>
<% end %> <%= if length(@import_progress.warnings) > 0 do %> <% end %>
""" end @doc """ Returns whether the Start Import button should be disabled. """ @spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) :: boolean() def import_button_disabled?(:running, _entries), do: true def import_button_disabled?(:preview, _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