mitgliederverwaltung/lib/mv_web/live/import_live/components.ex

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