prop/lib/microwaveprop_web/live/admin/contact_edit_live.ex
Graham McIntire 6f63f3bb5e
feat(admin): surface flagged contacts on the contact-edits page
Flagged contacts were only reachable from the contact detail page;
once the Flag column came off the /contacts index there was no
at-a-glance view of what's been flagged. Add a second table on the
admin contact-edits page (below the pending-edits table) that lists
every flagged contact newest-first with a direct View link. Hidden
entirely when nothing is flagged.
2026-04-19 12:40:20 -05:00

296 lines
9.7 KiB
Elixir

defmodule MicrowavepropWeb.Admin.ContactEditLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Radio
alias Microwaveprop.Radio.EditNotifier
def table_options do
%{exports: %{formats: [:csv]}}
end
def fields do
[
id: %{label: "ID", hidden: true},
contact_id: %{label: "Contact", renderer: &contact_cell/2},
user_id: %{label: "Submitted By", renderer: &user_cell/2},
proposed_changes: %{label: "Fields Changed", renderer: &changed_fields_cell/1},
inserted_at: %{label: "Submitted", sortable: true, renderer: &format_ts/1}
]
end
def filters, do: []
def actions do
[
review: fn %{record: edit} ->
assigns = %{edit: edit}
~H"""
<button
phx-click="review"
phx-value-id={@edit.id}
class="btn btn-primary btn-xs"
>
Review
</button>
"""
end
]
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Review Contact Edits")
|> assign(:reviewing, nil)
|> assign(:admin_note, "")
|> assign(:pending_count, Radio.pending_edit_count())
|> assign(:flagged_contacts, Radio.list_flagged_contacts())
|> assign(:data_provider, {Radio, :pending_edits_query, []})}
end
@impl true
def handle_event("review", %{"id" => id}, socket) do
edit = Radio.get_contact_edit!(id)
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
end
def handle_event("cancel_review", _params, socket) do
{:noreply, assign(socket, reviewing: nil, admin_note: "")}
end
def handle_event("update_note", %{"note" => note}, socket) do
{:noreply, assign(socket, admin_note: note)}
end
def handle_event("approve", _params, socket) do
edit = socket.assigns.reviewing
admin = socket.assigns.current_scope.user
note = normalize_note(socket.assigns.admin_note)
case Radio.approve_edit(edit, admin, note) do
{:ok, approved} ->
EditNotifier.deliver_edit_approved(approved)
{:noreply,
socket
|> assign(reviewing: nil, admin_note: "", pending_count: Radio.pending_edit_count())
|> put_flash(:info, "Edit approved and applied.")
|> push_patch(to: "/" <> (socket.assigns[:current_path] || "admin/contact-edits"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to approve edit.")}
end
end
def handle_event("reject", _params, socket) do
edit = socket.assigns.reviewing
admin = socket.assigns.current_scope.user
note = normalize_note(socket.assigns.admin_note)
case Radio.reject_edit(edit, admin, note) do
{:ok, rejected} ->
EditNotifier.deliver_edit_rejected(rejected)
{:noreply,
socket
|> assign(reviewing: nil, admin_note: "", pending_count: Radio.pending_edit_count())
|> put_flash(:info, "Edit rejected.")
|> push_patch(to: "/" <> (socket.assigns[:current_path] || "admin/contact-edits"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to reject edit.")}
end
end
defp normalize_note(""), do: nil
defp normalize_note(note), do: String.trim(note)
defp format_band(nil), do: "?"
defp format_band(band) do
mhz = Decimal.to_integer(band)
if mhz >= 1000, do: "#{div(mhz, 1000)} GHz", else: "#{mhz} MHz"
end
defp format_ts(nil), do: "?"
defp format_ts(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC")
defp changed_fields_summary(proposed_changes) do
proposed_changes
|> Map.keys()
|> Enum.sort()
|> Enum.join(", ")
end
defp field_label("station1"), do: "Station 1"
defp field_label("station2"), do: "Station 2"
defp field_label("grid1"), do: "Grid 1"
defp field_label("grid2"), do: "Grid 2"
defp field_label("band"), do: "Band"
defp field_label("mode"), do: "Mode"
defp field_label("qso_timestamp"), do: "Timestamp"
defp field_label(other), do: other
defp current_value(contact, "station1"), do: contact.station1
defp current_value(contact, "station2"), do: contact.station2
defp current_value(contact, "grid1"), do: contact.grid1
defp current_value(contact, "grid2"), do: contact.grid2
defp current_value(contact, "band"), do: if(contact.band, do: Decimal.to_string(contact.band))
defp current_value(contact, "mode"), do: contact.mode
defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp))
defp current_value(_contact, _), do: "?"
defp format_proposed("band", val), do: to_string(val)
defp format_proposed("qso_timestamp", val) when is_binary(val), do: val
defp format_proposed(_field, val), do: to_string(val)
defp contact_cell(_value, %{contact: contact}) when not is_nil(contact) do
assigns = %{contact: contact}
~H"""
<.link navigate={~p"/contacts/#{@contact.id}"} class="link link-primary">
{@contact.station1} / {@contact.station2}
</.link>
<div class="text-xs opacity-60">{format_band(@contact.band)}</div>
"""
end
defp contact_cell(_value, _record), do: ""
defp user_cell(_value, %{user: %{callsign: callsign}}), do: callsign
defp user_cell(_value, _record), do: ""
defp changed_fields_cell(changes) when is_map(changes), do: changed_fields_summary(changes)
defp changed_fields_cell(_), do: ""
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Review Contact Edits
<:subtitle>{@pending_count} pending</:subtitle>
<:actions>
<.link navigate={~p"/status"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Status
</.link>
</:actions>
</.header>
<%= if @reviewing do %>
<div class="bg-base-200 rounded-box p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg">
Review Edit from {@reviewing.user.callsign}
</h3>
<button phx-click="cancel_review" class="btn btn-ghost btn-sm">
<.icon name="hero-x-mark" class="w-4 h-4" /> Close
</button>
</div>
<div class="text-sm opacity-70 mb-4">
Contact:
<.link navigate={~p"/contacts/#{@reviewing.contact.id}"} class="link link-primary">
{@reviewing.contact.station1} / {@reviewing.contact.station2}
</.link>
&middot; {format_band(@reviewing.contact.band)} &middot; {format_ts(
@reviewing.contact.qso_timestamp
)}
</div>
<div class="overflow-x-auto rounded-box border border-base-300 mb-4">
<table class="table table-sm">
<thead>
<tr>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
<%= for {field, proposed_val} <- Enum.sort(@reviewing.proposed_changes) do %>
<tr>
<td class="font-semibold">{field_label(field)}</td>
<td class="opacity-70">{current_value(@reviewing.contact, field)}</td>
<td class="text-primary font-semibold">{format_proposed(field, proposed_val)}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="mb-4">
<label class="label text-sm">Note (optional)</label>
<textarea
class="textarea textarea-bordered w-full"
rows="2"
placeholder="Reason for decision..."
phx-change="update_note"
name="note"
>{@admin_note}</textarea>
</div>
<div class="flex gap-2">
<button phx-click="approve" class="btn btn-success btn-sm">
<.icon name="hero-check" class="w-4 h-4" /> Approve
</button>
<button phx-click="reject" class="btn btn-error btn-sm">
<.icon name="hero-x-mark" class="w-4 h-4" /> Reject
</button>
</div>
</div>
<% end %>
<.live_table
fields={fields()}
filters={filters()}
options={@options}
streams={@streams}
actions={actions()}
/>
<%= if @flagged_contacts != [] do %>
<div class="mt-10">
<h2 class="text-lg font-semibold mb-2">
Flagged contacts <span class="text-sm opacity-60">({length(@flagged_contacts)})</span>
</h2>
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table table-sm">
<thead>
<tr>
<th>Stations</th>
<th>Band</th>
<th>QSO (UTC)</th>
<th>Added (UTC)</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for c <- @flagged_contacts do %>
<tr>
<td class="font-semibold">{c.station1} / {c.station2}</td>
<td>{format_band(c.band)}</td>
<td class="opacity-70">{format_ts(c.qso_timestamp)}</td>
<td class="opacity-70">{format_ts(c.inserted_at)}</td>
<td>
<.link navigate={~p"/contacts/#{c.id}"} class="btn btn-xs btn-ghost">
View
</.link>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</Layouts.app>
"""
end
end