defmodule MicrowavepropWeb.ImportLive do @moduledoc """ Shows live progress for an async CSV/ADIF contact import. Mounts on `/imports/:id`, loads the `ImportRun`, and subscribes to the `"csv_import:"` PubSub topic so counters update as `ContactImportWorker` chunks finish. """ use MicrowavepropWeb, :live_view alias Microwaveprop.Radio.ImportRun alias Microwaveprop.Repo @pubsub Microwaveprop.PubSub @impl true def mount(%{"id" => id}, _session, socket) do case load_run(id) do nil -> {:ok, socket |> put_flash(:error, "Import not found.") |> push_navigate(to: ~p"/submit")} %ImportRun{} = run -> _ = if connected?(socket) do Phoenix.PubSub.subscribe(@pubsub, "csv_import:#{run.id}") end {:ok, socket |> assign(:page_title, "Import ##{String.slice(run.id, 0, 8)}") |> assign(:run, run)} end end @impl true def handle_info({:import_progress, _id, %ImportRun{} = updated}, socket) do {:noreply, assign(socket, :run, updated)} end defp load_run(id) do if valid_uuid?(id), do: Repo.get(ImportRun, id) end defp valid_uuid?(id) when is_binary(id) do case Ecto.UUID.cast(id) do {:ok, _} -> true :error -> false end end defp valid_uuid?(_), do: false @impl true def render(assigns) do ~H""" <.header> Contact Import <:subtitle> Submitted by {@run.submitter_email} · {@run.total_rows} {pluralize(@run.total_rows, "row")} <:actions> {status_label(@run.status)}
{@run.processed_rows} / {@run.total_rows} processed {progress_percent(@run)}%
Imported
{@run.imported_count}
New contacts inserted
Refined
{@run.refined_count}
Existing contacts updated
Errors
{@run.error_count}
Rows that failed at insert
<.icon name="hero-check-circle" class="w-6 h-6" />
Import complete
Enrichment (weather, terrain, propagation) will continue in the background.
<.icon name="hero-exclamation-triangle" class="w-6 h-6" /> Import failed before processing all rows.
0} id="import-errors-section" class="rounded-box border border-base-300" > {@run.error_count} {pluralize(@run.error_count, "row")} with errors
Row Errors
Row {row_num}
{msg}
<.link navigate={~p"/submit"} class="btn btn-outline"> <.icon name="hero-arrow-left" class="w-4 h-4" /> Upload another <.link :if={@run.status == "complete"} navigate={~p"/contacts"} class="btn btn-primary" > View contacts <.icon name="hero-arrow-right" class="w-4 h-4" />
""" end defp status_label("pending"), do: "Pending" defp status_label("running"), do: "Running" defp status_label("complete"), do: "Complete" defp status_label("failed"), do: "Failed" defp status_label(other), do: to_string(other) defp status_badge_class("pending"), do: "badge-neutral" defp status_badge_class("running"), do: "badge-info" defp status_badge_class("complete"), do: "badge-success" defp status_badge_class("failed"), do: "badge-error" defp status_badge_class(_), do: "badge-neutral" defp progress_class("complete"), do: "progress-success" defp progress_class("failed"), do: "progress-error" defp progress_class("running"), do: "progress-info" defp progress_class(_), do: "progress-primary" defp progress_percent(%ImportRun{total_rows: 0}), do: 0 defp progress_percent(%ImportRun{total_rows: total, processed_rows: processed}) do min(100, round(processed * 100 / total)) end defp pluralize(1, word), do: word defp pluralize(_, word), do: word <> "s" # `errors` is a JSONB map of "row_num" (string) → [messages]. Sort by # numeric row number for display; fall back to string order if a key # can't be parsed as an integer. defp sorted_errors(nil), do: [] defp sorted_errors(errors) when is_map(errors) do errors |> Enum.map(fn {row_num, messages} -> {row_num, List.wrap(messages)} end) |> Enum.sort_by(fn {row_num, _} -> case Integer.parse(to_string(row_num)) do {n, _} -> n :error -> :infinity end end) end end