- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign normalization, lat/lon validation, grid/latlon derivation across 6 schemas - Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue for Oban workers, UUID casting to replace CastError rescues - Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions), subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2 - Extract Propagation.ScoreStore (528 lines): separate file I/O and cache management from scoring logic, 13 defdelegate passthroughs - Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into 3 function component modules (csv_upload, adif_upload, preview) - Update 16 LiveViews to use shared helpers
260 lines
9.3 KiB
Elixir
260 lines
9.3 KiB
Elixir
defmodule MicrowavepropWeb.SubmitLive.PreviewComponent do
|
|
@moduledoc false
|
|
use MicrowavepropWeb, :html
|
|
|
|
alias Phoenix.LiveView.Rendered
|
|
|
|
@doc false
|
|
@spec csv_preview(map()) :: Rendered.t()
|
|
def 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),
|
|
refinement_count: length(assigns.preview.refinements || []),
|
|
valid_sample: Enum.take(assigns.preview.valid, 20),
|
|
refinement_sample: Enum.take(assigns.preview.refinements || [], 50)
|
|
)
|
|
|
|
~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-2 lg:grid-cols-4 gap-3">
|
|
<.summary_card
|
|
tone="success"
|
|
label="Valid"
|
|
value={@valid_count}
|
|
hint="Will be inserted"
|
|
/>
|
|
<.summary_card
|
|
tone="info"
|
|
label="Refinements"
|
|
value={@refinement_count}
|
|
hint="Will update existing contacts"
|
|
/>
|
|
<.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={@refinement_count > 0}>
|
|
<h3 class="font-semibold mb-2">Refinements ({@refinement_count})</h3>
|
|
<p class="text-xs opacity-60 mb-2">
|
|
These rows match an existing contact but add more precise data.
|
|
Confirming will update the existing contact in place.
|
|
</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>Changes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- @refinement_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 class="text-xs">
|
|
<div :for={{field, value} <- Enum.sort(Map.to_list(row.changes))}>
|
|
<span class="opacity-60">{field}:</span>
|
|
<code class="text-xs">{value}</code>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@refinement_count > length(@refinement_sample)} class="text-xs opacity-60 mt-1">
|
|
Showing first {length(@refinement_sample)} of {@refinement_count} refinements.
|
|
</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 or @refinement_count > 0}
|
|
type="button"
|
|
class="btn btn-primary btn-lg"
|
|
phx-click="confirm_csv"
|
|
phx-disable-with="Importing..."
|
|
data-confirm={confirm_prompt(@valid_count, @refinement_count)}
|
|
>
|
|
<.icon name="hero-check" class="w-5 h-5" />
|
|
{confirm_button_label(@valid_count, @refinement_count)}
|
|
</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
|
|
|
|
@doc false
|
|
@spec summary_card(map()) :: Rendered.t()
|
|
def 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"
|
|
"info" -> "border-info/30 bg-info/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 pluralize(1, word), do: word
|
|
defp pluralize(_, word), do: word <> "s"
|
|
|
|
defp confirm_button_label(valid, 0) do
|
|
"Looks good — submit #{valid} #{pluralize(valid, "contact")}"
|
|
end
|
|
|
|
defp confirm_button_label(0, refined) do
|
|
"Looks good — refine #{refined} existing #{pluralize(refined, "contact")}"
|
|
end
|
|
|
|
defp confirm_button_label(valid, refined) do
|
|
"Looks good — submit #{valid} and refine #{refined}"
|
|
end
|
|
|
|
defp confirm_prompt(valid, 0), do: "Import #{valid} contacts?"
|
|
defp confirm_prompt(0, refined), do: "Refine #{refined} existing contacts?"
|
|
|
|
defp confirm_prompt(valid, refined) do
|
|
"Import #{valid} contacts and refine #{refined} existing?"
|
|
end
|
|
end
|