mitgliederverwaltung/lib/mv_web/live/import_live/components.ex
2026-02-24 15:27:12 +01:00

270 lines
8.3 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 with a warning. Groups and membership fees are not supported for import."
)}
</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="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"
>
<fieldset class="mb-2 fieldset w-md">
<label for="csv_file">
<span class="mb-1 label">{gettext("CSV File")}</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered"
aria-describedby="csv_file_help"
/>
<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 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