prop/lib/microwaveprop_web/live/submit_live/preview_component.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- 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
2026-08-06 18:06:50 -05:00

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