1. Prevent user_id impersonation in contact submissions - Remove user_id from submission_changeset cast fields - Pass user_id as a separate server-side argument to create_contact/2 - Client form params can no longer forge account-linked contacts 2. Restrict flag toggle to admins only - Add admin? guard to toggle_flag event handler - Hide flag button from non-admin users in template 3. Reset enrichment on timestamp edits - Add qso_timestamp to the fields that trigger enrichment reset - Prevents stale weather/HRRR/IEMRE data after time changes
827 lines
27 KiB
Elixir
827 lines
27 KiB
Elixir
defmodule MicrowavepropWeb.SubmitLive do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :live_view
|
|
use LiveStash
|
|
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Radio.AdifImport
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Radio.CsvImport
|
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
|
|
|
@band_options BandConfig.band_options()
|
|
|
|
@mode_options ~w(CW SSB FM FT8 FT4 Q65)
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
socket =
|
|
socket
|
|
|> allow_upload(:csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000)
|
|
|> allow_upload(:adif, accept: :any, max_entries: 1, max_file_size: 10_000_000)
|
|
|
|
changeset = Radio.change_contact(%Contact{})
|
|
|
|
socket =
|
|
assign(socket,
|
|
page_title: "Submit Contact",
|
|
form: to_form(changeset),
|
|
band_options: @band_options,
|
|
mode_options: @mode_options,
|
|
submitted_at: nil,
|
|
active_tab: :single,
|
|
csv_preview: nil,
|
|
csv_result: nil,
|
|
csv_email: ""
|
|
)
|
|
|
|
case LiveStash.recover_state(socket) do
|
|
{:recovered, socket} ->
|
|
{:ok, socket}
|
|
|
|
_ ->
|
|
{:ok, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
|
socket = assign(socket, active_tab: String.to_existing_atom(tab))
|
|
{:noreply, LiveStash.stash_assigns(socket, [:active_tab])}
|
|
end
|
|
|
|
def handle_event("validate", %{"contact" => contact_params}, socket) do
|
|
changeset =
|
|
%Contact{}
|
|
|> Radio.change_contact(contact_params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, form: to_form(changeset))}
|
|
end
|
|
|
|
def handle_event("validate_csv", _params, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("validate_adif", _params, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# Minimum milliseconds between submissions per LiveView session.
|
|
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
|
|
@submission_cooldown_ms 30_000
|
|
|
|
def handle_event("save", %{"contact" => contact_params}, socket) do
|
|
if recently_submitted?(socket) do
|
|
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
|
|
else
|
|
{contact_params, user_id} = merge_user_params(contact_params, socket)
|
|
|
|
case Radio.create_contact(contact_params, user_id) do
|
|
{:ok, contact} ->
|
|
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(submitted_at: System.monotonic_time(:millisecond))
|
|
|> put_flash(:info, "Contact submitted successfully!")
|
|
|> push_navigate(to: ~p"/contacts/#{contact.id}")}
|
|
|
|
{:error, :duplicate, existing} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(
|
|
:error,
|
|
"A matching contact already exists: #{existing.station1} ↔ #{existing.station2} on #{existing.band} MHz"
|
|
)
|
|
|> push_navigate(to: ~p"/contacts/#{existing.id}")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, form: to_form(changeset))}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("upload_csv", params, socket) do
|
|
email =
|
|
case current_user(socket.assigns) do
|
|
%{email: user_email} -> user_email
|
|
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
|
end
|
|
|
|
if email == "" do
|
|
{:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
|
|
else
|
|
uploaded_contents =
|
|
consume_uploaded_entries(socket, :csv, fn %{path: path}, _entry ->
|
|
{:ok, File.read!(path)}
|
|
end)
|
|
|
|
case uploaded_contents do
|
|
[content] ->
|
|
handle_csv_preview(content, email, socket)
|
|
|
|
[] ->
|
|
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_csv_upload", %{"ref" => ref}, socket) do
|
|
{:noreply, cancel_upload(socket, :csv, ref)}
|
|
end
|
|
|
|
def handle_event("upload_adif", params, socket) do
|
|
email =
|
|
case current_user(socket.assigns) do
|
|
%{email: user_email} -> user_email
|
|
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
|
end
|
|
|
|
if email == "" do
|
|
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
|
|
else
|
|
uploaded_contents =
|
|
consume_uploaded_entries(socket, :adif, fn %{path: path}, _entry ->
|
|
{:ok, File.read!(path)}
|
|
end)
|
|
|
|
case uploaded_contents do
|
|
[content] ->
|
|
handle_adif_preview(content, email, socket)
|
|
|
|
[] ->
|
|
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_adif_upload", %{"ref" => ref}, socket) do
|
|
{:noreply, cancel_upload(socket, :adif, ref)}
|
|
end
|
|
|
|
def handle_event("confirm_csv", _params, socket) do
|
|
case socket.assigns.csv_preview do
|
|
%{valid: valid_rows} when valid_rows != [] ->
|
|
{:ok, commit_result} = CsvImport.commit(valid_rows)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(csv_preview: nil, csv_result: commit_result)
|
|
|> put_flash(:info, "#{length(commit_result.imported)} contacts submitted.")}
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, "Nothing to import.")}
|
|
end
|
|
end
|
|
|
|
def handle_event("cancel_csv", _params, socket) do
|
|
{:noreply, assign(socket, csv_preview: nil, csv_result: nil)}
|
|
end
|
|
|
|
def handle_event("reset_csv", _params, socket) do
|
|
{:noreply, assign(socket, csv_preview: nil, csv_result: nil)}
|
|
end
|
|
|
|
defp handle_adif_preview(content, email, socket) do
|
|
case AdifImport.preview(content, email) do
|
|
{:ok, preview} ->
|
|
{:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)}
|
|
|
|
{:error, :no_records} ->
|
|
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
|
|
end
|
|
end
|
|
|
|
defp handle_csv_preview(content, email, socket) do
|
|
case CsvImport.preview(content, email) do
|
|
{:ok, preview} ->
|
|
{:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)}
|
|
|
|
{:error, :empty_csv} ->
|
|
{:noreply, put_flash(socket, :error, "CSV file is empty")}
|
|
|
|
{:error, :no_data_rows} ->
|
|
{:noreply, put_flash(socket, :error, "CSV file has no data rows")}
|
|
end
|
|
end
|
|
|
|
defp recently_submitted?(socket) do
|
|
case socket.assigns.submitted_at do
|
|
nil -> false
|
|
ts -> System.monotonic_time(:millisecond) - ts < @submission_cooldown_ms
|
|
end
|
|
end
|
|
|
|
defp merge_user_params(params, socket) do
|
|
case current_user(socket.assigns) do
|
|
%{id: user_id, email: user_email} ->
|
|
{Map.put(params, "submitter_email", user_email), user_id}
|
|
|
|
_ ->
|
|
{params, nil}
|
|
end
|
|
end
|
|
|
|
defp error_to_string(:too_large), do: "File is too large"
|
|
defp error_to_string(:too_many_files), do: "Only one file allowed"
|
|
defp error_to_string(:not_accepted), do: "File type not accepted"
|
|
defp error_to_string(other), do: to_string(other)
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<.header>
|
|
Submit Contact
|
|
<:subtitle>Help us build a better propagation model</:subtitle>
|
|
</.header>
|
|
|
|
<div class="alert alert-info mb-6 text-sm leading-relaxed">
|
|
<div>
|
|
<p class="font-semibold mb-1">Every contact matters</p>
|
|
<p>
|
|
Our propagation model improves with real-world data. Each verified contact
|
|
you submit is matched against atmospheric conditions at the time of your contact,
|
|
helping us calibrate predictions for all amateur bands from 902 MHz and up. The
|
|
more contacts we have, the better our forecasts get for everyone.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert mb-6 text-sm leading-relaxed">
|
|
<p>
|
|
Your contact will be available on the
|
|
<.link navigate="/contacts" class="link link-primary">contacts page</.link>
|
|
shortly after submission, with terrain analysis, atmospheric data, and propagation scoring
|
|
added automatically.
|
|
</p>
|
|
</div>
|
|
|
|
<div role="tablist" class="tabs tabs-box mb-6">
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :single && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="single"
|
|
>
|
|
Single Contact
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :csv && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="csv"
|
|
>
|
|
Upload CSV
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :adif && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="adif"
|
|
>
|
|
Upload ADIF
|
|
</button>
|
|
</div>
|
|
|
|
<%= case @active_tab do %>
|
|
<% :single -> %>
|
|
<.single_contact_form
|
|
form={@form}
|
|
band_options={@band_options}
|
|
mode_options={@mode_options}
|
|
current_user={current_user(assigns)}
|
|
/>
|
|
<% :csv -> %>
|
|
<%= cond do %>
|
|
<% @csv_result -> %>
|
|
<.csv_results result={@csv_result} />
|
|
<% @csv_preview -> %>
|
|
<.csv_preview preview={@csv_preview} />
|
|
<% true -> %>
|
|
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
|
<% end %>
|
|
<% :adif -> %>
|
|
<%= cond do %>
|
|
<% @csv_result -> %>
|
|
<.csv_results result={@csv_result} />
|
|
<% @csv_preview -> %>
|
|
<.csv_preview preview={@csv_preview} />
|
|
<% true -> %>
|
|
<.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
|
<% end %>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
# In-template variant — takes an assigns map directly (not a socket).
|
|
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
|
|
defp current_user(_), do: nil
|
|
|
|
defp single_contact_form(assigns) do
|
|
~H"""
|
|
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" required />
|
|
<.input
|
|
field={@form[:grid1]}
|
|
type="text"
|
|
label="Grid 1"
|
|
placeholder="EM12kp"
|
|
phx-debounce="blur"
|
|
required
|
|
/>
|
|
<.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" required />
|
|
<.input
|
|
field={@form[:grid2]}
|
|
type="text"
|
|
label="Grid 2"
|
|
placeholder="EM00cd"
|
|
phx-debounce="blur"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<p class="text-sm text-base-content/60 -mt-2">
|
|
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).
|
|
</p>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<.input
|
|
field={@form[:band]}
|
|
type="select"
|
|
label="Band"
|
|
prompt="Select band"
|
|
options={@band_options}
|
|
required
|
|
/>
|
|
<.input
|
|
field={@form[:mode]}
|
|
type="select"
|
|
label="Mode"
|
|
prompt="Select mode"
|
|
options={@mode_options}
|
|
required
|
|
/>
|
|
<.input
|
|
field={@form[:qso_timestamp]}
|
|
type="datetime-local"
|
|
label="Timestamp (UTC)"
|
|
lang="en-GB"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input
|
|
type="hidden"
|
|
name="contact[submitter_email]"
|
|
value={@current_user.email}
|
|
/>
|
|
<% else %>
|
|
<.input
|
|
field={@form[:submitter_email]}
|
|
type="email"
|
|
label="Your Email"
|
|
placeholder="you@example.com"
|
|
required
|
|
/>
|
|
<% end %>
|
|
|
|
<div class="mt-6">
|
|
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
defp csv_upload_form(assigns) do
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div class="alert text-sm">
|
|
<div>
|
|
<p class="mb-2">
|
|
Upload a CSV file with multiple contacts. Required columns:
|
|
</p>
|
|
<code class="text-xs">station1, station2, grid1, grid2, band, mode, qso_timestamp</code>
|
|
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
|
|
<li>
|
|
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>, <code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
|
|
</li>
|
|
<li>
|
|
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
|
|
</li>
|
|
<li>Band in MHz (e.g. 10000, 24000)</li>
|
|
</ul>
|
|
<p class="mt-2">
|
|
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
|
|
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">CSV File</span>
|
|
<.live_file_input upload={@uploads.csv} class="file-input file-input-bordered w-full" />
|
|
</label>
|
|
</div>
|
|
|
|
<div :for={entry <- @uploads.csv.entries} class="space-y-1">
|
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
<span class="truncate pr-2">{entry.client_name}</span>
|
|
<span class="flex items-center gap-2 shrink-0">
|
|
<span>{entry.progress}%</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
phx-click="cancel_csv_upload"
|
|
phx-value-ref={entry.ref}
|
|
aria-label="Cancel upload"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<progress
|
|
class="progress progress-primary w-full"
|
|
value={entry.progress}
|
|
max="100"
|
|
>
|
|
</progress>
|
|
<div :for={err <- upload_errors(@uploads.csv, entry)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.csv)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
<% else %>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">Your Email</span>
|
|
<input
|
|
type="email"
|
|
name="submitter_email"
|
|
class="w-full input"
|
|
placeholder="you@example.com"
|
|
value=""
|
|
/>
|
|
</label>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-6">
|
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp adif_upload_form(assigns) do
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div class="alert text-sm">
|
|
<div>
|
|
<p class="mb-2">
|
|
Upload an ADIF (.adi / .adif) file exported from your logging program.
|
|
</p>
|
|
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
|
|
<li>
|
|
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
|
|
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
|
|
and <code>FREQ</code>
|
|
or <code>BAND</code>
|
|
</li>
|
|
<li>Only microwave contacts (902 MHz and up) will be imported</li>
|
|
<li>Sub-microwave contacts are silently skipped</li>
|
|
<li>Frequencies are fuzzy-matched to the nearest microwave band</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
id="adif-upload-form"
|
|
phx-submit="upload_adif"
|
|
phx-change="validate_adif"
|
|
class="space-y-4"
|
|
>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">ADIF File</span>
|
|
<.live_file_input upload={@uploads.adif} class="file-input file-input-bordered w-full" />
|
|
</label>
|
|
</div>
|
|
|
|
<div :for={entry <- @uploads.adif.entries} class="space-y-1">
|
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
<span class="truncate pr-2">{entry.client_name}</span>
|
|
<span class="flex items-center gap-2 shrink-0">
|
|
<span>{entry.progress}%</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
phx-click="cancel_adif_upload"
|
|
phx-value-ref={entry.ref}
|
|
aria-label="Cancel upload"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<progress
|
|
class="progress progress-primary w-full"
|
|
value={entry.progress}
|
|
max="100"
|
|
>
|
|
</progress>
|
|
<div :for={err <- upload_errors(@uploads.adif, entry)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.adif)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
<% else %>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">Your Email</span>
|
|
<input
|
|
type="email"
|
|
name="submitter_email"
|
|
class="w-full input"
|
|
placeholder="you@example.com"
|
|
value=""
|
|
/>
|
|
</label>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-6">
|
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp csv_preview(assigns) do
|
|
assigns =
|
|
assign(assigns,
|
|
valid_count: length(assigns.preview.valid),
|
|
invalid_count: length(assigns.preview.invalid),
|
|
duplicate_count: length(assigns.preview.duplicates),
|
|
valid_sample: Enum.take(assigns.preview.valid, 20)
|
|
)
|
|
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold mb-2">Review before submitting</h2>
|
|
<p class="text-sm opacity-70">
|
|
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}.
|
|
Nothing has been inserted yet — confirm below to import.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<.summary_card
|
|
tone="success"
|
|
label="Valid"
|
|
value={@valid_count}
|
|
hint="Will be inserted"
|
|
/>
|
|
<.summary_card
|
|
tone="warning"
|
|
label="Duplicates"
|
|
value={@duplicate_count}
|
|
hint="Match existing or earlier rows"
|
|
/>
|
|
<.summary_card
|
|
tone="error"
|
|
label="Invalid"
|
|
value={@invalid_count}
|
|
hint="Skipped — see errors"
|
|
/>
|
|
</div>
|
|
|
|
<div :if={@invalid_count > 0}>
|
|
<h3 class="font-semibold mb-2">Invalid rows ({@invalid_count})</h3>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-24">Row</th>
|
|
<th>Errors</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- Enum.take(@preview.invalid, 100)}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>
|
|
<div :for={msg <- row.messages}>{msg}</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@invalid_count > 100} class="text-xs opacity-60 mt-1">
|
|
Showing first 100 of {@invalid_count} invalid rows.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@duplicate_count > 0}>
|
|
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
|
|
<p class="text-xs opacity-60 mb-2">
|
|
Same two callsigns at the same grids on the same band within an hour.
|
|
</p>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Timestamp</th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- Enum.take(@preview.duplicates, 100)}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td class="font-mono text-xs">
|
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
<td class="text-xs opacity-70">{duplicate_source(row.reason)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@duplicate_count > 100} class="text-xs opacity-60 mt-1">
|
|
Showing first 100 of {@duplicate_count} duplicates.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@valid_count > 0}>
|
|
<h3 class="font-semibold mb-2">Valid rows ready to import ({@valid_count})</h3>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Mode</th>
|
|
<th>Timestamp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- @valid_sample}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td>{row.attrs["mode"]}</td>
|
|
<td class="font-mono text-xs">
|
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@valid_count > length(@valid_sample)} class="text-xs opacity-60 mt-1">
|
|
Showing first {length(@valid_sample)} of {@valid_count} valid rows.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
|
<button
|
|
:if={@valid_count > 0}
|
|
type="button"
|
|
class="btn btn-primary btn-lg"
|
|
phx-click="confirm_csv"
|
|
phx-disable-with="Importing..."
|
|
data-confirm={"Import #{@valid_count} contacts?"}
|
|
>
|
|
<.icon name="hero-check" class="w-5 h-5" />
|
|
Looks good — submit {@valid_count} {if @valid_count == 1,
|
|
do: "contact",
|
|
else: "contacts"}
|
|
</button>
|
|
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :tone, :string, required: true
|
|
attr :label, :string, required: true
|
|
attr :value, :integer, required: true
|
|
attr :hint, :string, required: true
|
|
|
|
defp summary_card(assigns) do
|
|
~H"""
|
|
<div class={[
|
|
"rounded-box border p-4",
|
|
case @tone do
|
|
"success" -> "border-success/30 bg-success/10"
|
|
"warning" -> "border-warning/30 bg-warning/10"
|
|
"error" -> "border-error/30 bg-error/10"
|
|
_ -> "border-base-300 bg-base-200"
|
|
end
|
|
]}>
|
|
<div class="text-xs uppercase tracking-wider opacity-70">{@label}</div>
|
|
<div class="text-3xl font-bold tabular-nums">{@value}</div>
|
|
<div class="text-xs opacity-60">{@hint}</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp duplicate_source(:existing_contact), do: "Already in database"
|
|
defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload"
|
|
defp duplicate_source(_), do: "Duplicate"
|
|
|
|
defp csv_results(assigns) do
|
|
assigns =
|
|
assign(assigns,
|
|
imported_count: length(assigns.result.imported),
|
|
error_count: length(assigns.result.errors)
|
|
)
|
|
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div
|
|
:if={@imported_count > 0}
|
|
class="alert alert-success p-8 text-center"
|
|
>
|
|
<.icon name="hero-check-circle" class="w-16 h-16 text-success mx-auto mb-3" />
|
|
<div class="text-3xl font-bold tabular-nums">
|
|
{@imported_count} {if @imported_count == 1, do: "contact", else: "contacts"} submitted
|
|
</div>
|
|
<p class="text-sm opacity-70 mt-2">
|
|
Weather, terrain, and propagation data will be attached shortly.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@error_count > 0} class="space-y-2">
|
|
<div class="alert alert-error">
|
|
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
|
<span>
|
|
{@error_count} {if @error_count == 1, do: "row", else: "rows"} had errors at insert time
|
|
</span>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Row</th>
|
|
<th>Errors</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={{row_num, messages} <- @result.errors}>
|
|
<td class="font-mono">Row {row_num}</td>
|
|
<td>
|
|
<div :for={msg <- messages}>{msg}</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" class="btn btn-outline" phx-click="reset_csv">
|
|
Upload another
|
|
</button>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|