defmodule MicrowavepropWeb.ContactLive.Index do @moduledoc false use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Radio.Contact import Ecto.Query alias Microwaveprop.Accounts.Scope alias Microwaveprop.Accounts.User alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo def table_options do %{ sorting: %{default_sort: [inserted_at: :desc]}, exports: %{formats: [:csv]} } end def fields do [ id: %{label: "ID", hidden: true}, station1: %{label: "Station 1", sortable: true, searchable: true}, grid1: %{label: "Grid 1", sortable: true, searchable: true}, station2: %{label: "Station 2", sortable: true, searchable: true}, grid2: %{label: "Grid 2", sortable: true, searchable: true}, band: %{label: "Band", sortable: true, renderer: &band_cell/1}, mode: %{label: "Mode", sortable: true, searchable: true}, distance_km: %{label: "Distance", sortable: true, renderer: &distance_cell/1}, hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2}, qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1}, inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1}, private: %{label: "Private", sortable: true, renderer: &private_cell/1} ] end def filters, do: [] def actions do [ view: fn %{record: contact} -> assigns = %{contact: contact} ~H""" <.link navigate={~p"/contacts/#{@contact.id}"} class="btn btn-xs btn-ghost"> View """ end ] end @impl true def mount(_params, _session, socket) do scope = socket.assigns[:current_scope] base_query = visible_query(scope) total = Repo.aggregate(base_query, :count, :id) {:ok, socket |> assign(:page_title, "Contacts") |> assign(:total_contacts, total) |> assign(:visible_fields, visible_fields_for(scope)) |> assign(:data_provider, {__MODULE__, :visible_query_provider, [scope_token(scope)]})} end # Hide the Private column entirely unless this scope can actually see # at least one private contact — avoids an always-empty column for # visitors and owners who've never flagged one as private. defp visible_fields_for(scope) do if has_viewable_private?(scope) do fields() else Keyword.delete(fields(), :private) end end defp has_viewable_private?(%Scope{user: %User{is_admin: true}}) do Repo.exists?(from(c in Contact, where: c.private == true)) end defp has_viewable_private?(%Scope{user: %User{id: user_id}}) do Repo.exists?(from(c in Contact, where: c.private == true and c.user_id == ^user_id)) end defp has_viewable_private?(_), do: false @doc false # Called by LiveTable to get the base queryable. Must be a # named function because the helper persists MFA across renders. def visible_query_provider(scope_token) do scope_token |> scope_from_token() |> visible_query() |> from(as: :resource) end defp visible_query(%Scope{user: %User{is_admin: true}}), do: Contact defp visible_query(%Scope{user: %User{id: user_id}}) do from(c in Contact, where: c.private == false or c.user_id == ^user_id) end defp visible_query(_), do: from(c in Contact, where: c.private == false) # The data_provider MFA is serialized into the socket; a raw %Scope{} # with associations isn't safe to store there. Collapse to the minimum # needed to rebuild visibility and rehydrate on each invocation. defp scope_token(%Scope{user: %User{id: id, is_admin: is_admin}}), do: {id, is_admin} defp scope_token(_), do: nil defp scope_from_token({id, true}), do: %Scope{user: %User{id: id, is_admin: true}} defp scope_from_token({id, false}), do: %Scope{user: %User{id: id, is_admin: false}} defp scope_from_token(nil), do: nil defp private_cell(true) do assigns = %{} ~H""" Yes """ end defp private_cell(_), do: "" @enrichment_fields [ {:hrrr_status, "HRRR"}, {:weather_status, "Weather"}, {:terrain_status, "Terrain"}, {:iemre_status, "IEMRE"} ] @done_statuses [:complete, :unavailable] defp enrichment_cell(_value, contact) do details = Enum.map(@enrichment_fields, fn {field, name} -> {name, Map.get(contact, field)} end) done = Enum.count(details, fn {_, s} -> s in @done_statuses end) failed = Enum.count(details, fn {_, s} -> s == :failed end) {label, class} = cond do done == 4 -> {"Complete", "badge-success"} failed > 0 -> {"Failed", "badge-error"} done > 0 -> {"Partial", "badge-warning"} true -> {"Pending", "badge-ghost"} end tooltip = Enum.map_join(details, ", ", fn {name, status} -> "#{name}: #{status}" end) assigns = %{label: label, class: class, tooltip: tooltip} ~H""" {@label} """ end defp band_cell(nil), do: "" defp band_cell(%Decimal{} = d), do: Decimal.to_string(d, :normal) defp band_cell(other), do: to_string(other) defp distance_cell(nil), do: "" defp distance_cell(km), do: Microwaveprop.Format.distance_km(km) defp format_count(n) when is_integer(n) do n |> Integer.to_string() |> String.reverse() |> String.replace(~r/(\d{3})(?=\d)/, "\\1,") |> String.reverse() end defp format_ts(nil), do: "" defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") defp format_ts(other), do: to_string(other) @impl true def render(assigns) do ~H""" <.header> Contacts <:subtitle> {format_count(@total_contacts)} total — sort or search by callsign, grid, or mode. <:actions> <.link navigate={~p"/submit"} class="btn btn-primary"> <.icon name="hero-plus" class="w-5 h-5" /> Submit Contact <.live_table fields={@visible_fields} filters={filters()} options={@options} streams={@streams} actions={actions()} /> """ end end