prop/lib/microwaveprop_web/live/import_live.ex
Graham McIntire b4a36890da
feat(import): fire confetti when the complete banner appears
Vendors canvas-confetti 1.9.3 and wires an ImportConfetti hook on the
alert-success banner that /imports/:id already renders when
run.status == "complete". The hook uses the "realistic look" preset
from confetti.js.org and runs on mount, so it fires exactly once —
when the banner first appears.
2026-04-17 10:01:18 -05:00

223 lines
7.1 KiB
Elixir

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:<id>"` 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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Contact Import
<:subtitle>
Submitted by <span class="font-mono">{@run.submitter_email}</span>
&middot; {@run.total_rows} {pluralize(@run.total_rows, "row")}
</:subtitle>
<:actions>
<span id="import-status-badge" class={["badge badge-lg", status_badge_class(@run.status)]}>
{status_label(@run.status)}
</span>
</:actions>
</.header>
<div class="space-y-6">
<div>
<div class="flex items-center justify-between text-sm mb-1 tabular-nums">
<span>
{@run.processed_rows} / {@run.total_rows} processed
</span>
<span>{progress_percent(@run)}%</span>
</div>
<progress
id="import-progress-bar"
class={["progress w-full", progress_class(@run.status)]}
value={@run.processed_rows}
max={max(@run.total_rows, 1)}
>
</progress>
</div>
<div class="stats stats-vertical sm:stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">Imported</div>
<div id="import-imported-count" class="stat-value text-success tabular-nums">
{@run.imported_count}
</div>
<div class="stat-desc">New contacts inserted</div>
</div>
<div class="stat">
<div class="stat-title">Refined</div>
<div id="import-refined-count" class="stat-value text-info tabular-nums">
{@run.refined_count}
</div>
<div class="stat-desc">Existing contacts updated</div>
</div>
<div class="stat">
<div class="stat-title">Errors</div>
<div id="import-error-count" class="stat-value text-error tabular-nums">
{@run.error_count}
</div>
<div class="stat-desc">Rows that failed at insert</div>
</div>
</div>
<div
:if={@run.status == "complete"}
id="import-complete-panel"
class="alert alert-success"
phx-hook="ImportConfetti"
>
<.icon name="hero-check-circle" class="w-6 h-6" />
<div>
<div class="font-semibold">Import complete</div>
<div class="text-sm opacity-90">
Enrichment (weather, terrain, propagation) will continue in the background.
</div>
</div>
</div>
<div
:if={@run.status == "failed"}
id="import-failed-panel"
class="alert alert-error"
>
<.icon name="hero-exclamation-triangle" class="w-6 h-6" />
<span>Import failed before processing all rows.</span>
</div>
<details
:if={@run.error_count > 0}
id="import-errors-section"
class="rounded-box border border-base-300"
>
<summary class="cursor-pointer p-4 font-semibold">
{@run.error_count} {pluralize(@run.error_count, "row")} with errors
</summary>
<div class="overflow-x-auto p-4 pt-0">
<table class="table table-sm">
<thead>
<tr>
<th class="w-24">Row</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<tr :for={{row_num, messages} <- sorted_errors(@run.errors)}>
<td class="font-mono">Row {row_num}</td>
<td>
<div :for={msg <- messages}>{msg}</div>
</td>
</tr>
</tbody>
</table>
</div>
</details>
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
<.link navigate={~p"/submit"} class="btn btn-outline">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Upload another
</.link>
<.link
:if={@run.status == "complete"}
navigate={~p"/contacts"}
class="btn btn-primary"
>
View contacts <.icon name="hero-arrow-right" class="w-4 h-4" />
</.link>
</div>
</div>
</Layouts.app>
"""
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