defmodule MicrowavepropWeb.Admin.ContactEditLive do @moduledoc "Admin-only contact editor at `/admin/contacts/:id/edit` for manual QSO fixes." use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, 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""" """ 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}
{format_band(@contact.band)}
""" 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""" <.header> Review Contact Edits <:subtitle>{@pending_count} pending <:actions> <.link navigate={~p"/status"} class="btn btn-sm btn-ghost"> <.icon name="hero-arrow-left" class="w-4 h-4" /> Status <%= if @reviewing do %>

Review Edit from {@reviewing.user.callsign}

Contact: <.link navigate={~p"/contacts/#{@reviewing.contact.id}"} class="link link-primary"> {@reviewing.contact.station1} / {@reviewing.contact.station2} · {format_band(@reviewing.contact.band)} · {format_ts( @reviewing.contact.qso_timestamp )}
<%= for {field, proposed_val} <- Enum.sort(@reviewing.proposed_changes) do %> <% end %>
Field Current Proposed
{field_label(field)} {current_value(@reviewing.contact, field)} {format_proposed(field, proposed_val)}
<% end %> <.live_table fields={fields()} filters={filters()} options={@options} streams={@streams} actions={actions()} /> <%= if @flagged_contacts != [] do %>

Flagged contacts ({length(@flagged_contacts)})

<%= for c <- @flagged_contacts do %> <% end %>
Stations Band QSO (UTC) Added (UTC)
{c.station1} / {c.station2} {format_band(c.band)} {format_ts(c.qso_timestamp)} {format_ts(c.inserted_at)} <.link navigate={~p"/contacts/#{c.id}"} class="btn btn-xs btn-ghost"> View
<% end %>
""" end end