459 lines
15 KiB
Elixir
459 lines
15 KiB
Elixir
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"""
|
|
<div role="note" class="alert alert-info mb-4 w-xl">
|
|
<.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, because unknown data field columns will be ignored."
|
|
)}
|
|
</p>
|
|
<p class="text-sm mb-2">
|
|
{gettext(
|
|
"Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
|
)}
|
|
</p>
|
|
<p class="text-sm mb-2">
|
|
{gettext(
|
|
"Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
|
)}
|
|
</p>
|
|
<p class="text-sm">
|
|
{gettext(
|
|
"Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
|
)}
|
|
</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="mb-2">
|
|
{gettext("Download CSV templates:")}
|
|
</p>
|
|
<ul class="list-disc list-inside space-y-1">
|
|
<li>
|
|
<.link href={~p"/admin/import/template/en"} class="link link-primary">
|
|
{gettext("English Template")}
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link href={~p"/admin/import/template/de"} 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"
|
|
>
|
|
<fieldset class="mb-2 fieldset w-md" aria-labelledby="csv_file_label">
|
|
<label id="csv_file_label" for="csv_file" class="label block">
|
|
<span class="mb-1 label text-base-content">{gettext("CSV File")}</span>
|
|
<.live_file_input
|
|
upload={@uploads.csv_file}
|
|
id="csv_file"
|
|
class="file-input file-input-bordered block"
|
|
aria-describedby="csv_file_help"
|
|
aria-label={gettext("CSV File")}
|
|
/>
|
|
</label>
|
|
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
|
|
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
|
</p>
|
|
</fieldset>
|
|
|
|
<.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 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"""
|
|
<section
|
|
class="mt-4 space-y-4"
|
|
data-testid="import-preview"
|
|
aria-labelledby="import-preview-heading"
|
|
>
|
|
<h2 id="import-preview-heading" class="text-lg font-semibold">
|
|
{gettext("Preview import")}
|
|
</h2>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm w-full" data-testid="preview-mapping-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{gettext("Role")}</th>
|
|
<th>{gettext("Column")}</th>
|
|
<th class="text-base-content/60">{gettext("Row 1")}</th>
|
|
<th class="text-base-content/60">{gettext("Row 2")}</th>
|
|
<th class="text-base-content/60">{gettext("Row 3")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %>
|
|
<tr class={role_row_class(role)} data-testid="preview-column-row">
|
|
<td>
|
|
<span class={"badge badge-sm #{role_badge_class(role)}"}>
|
|
{role_label(role)}
|
|
</span>
|
|
</td>
|
|
<td class="font-medium">{header}</td>
|
|
<%= for sample <- samples do %>
|
|
<td class="text-base-content/70 max-w-32 truncate">{sample}</td>
|
|
<% end %>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<%= if @import_state.groups_to_create != [] do %>
|
|
<div class="alert alert-info" role="note" data-testid="preview-groups-notice">
|
|
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
|
<div>
|
|
<p class="text-sm">
|
|
{gettext("These groups will be created automatically: %{names}",
|
|
names: Enum.join(@import_state.groups_to_create, ", ")
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @import_state.fee_type_warnings != [] do %>
|
|
<div class="alert alert-warning" role="alert" data-testid="preview-fee-type-warning">
|
|
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
|
<div>
|
|
<p class="text-sm">
|
|
{gettext("Unknown fee types (members get the default): %{names}",
|
|
names: Enum.join(@import_state.fee_type_warnings, ", ")
|
|
)}
|
|
</p>
|
|
<.link
|
|
navigate={~p"/membership_fee_settings/new_fee_type"}
|
|
class="link link-primary text-sm"
|
|
>
|
|
{gettext("Create fee type")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @import_state.has_empty_fee_type_cells? do %>
|
|
<div class="alert alert-info" role="note" data-testid="preview-fee-type-info">
|
|
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
|
<div>
|
|
<p class="text-sm">
|
|
{gettext("Rows with an empty fee type will get the default fee type.")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @import_state.warnings != [] do %>
|
|
<div class="alert alert-warning" role="alert" data-testid="preview-unknown-warning">
|
|
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
|
<div>
|
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
|
<%= for warning <- @import_state.warnings do %>
|
|
<li>{warning}</li>
|
|
<% end %>
|
|
</ul>
|
|
<.link navigate={~p"/admin/datafields"} class="link link-primary text-sm">
|
|
{gettext("Create custom field")}
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="flex gap-2">
|
|
<.button
|
|
type="button"
|
|
phx-click="confirm_import"
|
|
variant="primary"
|
|
data-testid="confirm-import-button"
|
|
>
|
|
{gettext("Confirm and Import")}
|
|
</.button>
|
|
<.button type="button" phx-click="cancel_import" data-testid="cancel-import-button">
|
|
{gettext("Cancel")}
|
|
</.button>
|
|
</div>
|
|
</section>
|
|
"""
|
|
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 %>
|
|
<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 | :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
|