towerops/lib/towerops_web/live/coverage_live/form.html.heex
Graham McIntire 5a9381f91a feat: add /coverage RF prediction feature scaffold
End-to-end CRUD scaffold for per-site, per-antenna RF coverage
prediction. Schema, context, antenna registry, Oban worker stub, and
three LiveViews are wired up; the actual ITM + LIDAR + buildings
compute pipeline lands in a follow-up.

- Migration + Coverage schema with full validation (RF params, pixel-
  budget cap, antenna_slug existence). organization_id excluded from
  cast (programmatic-only).
- Coverages context: org-scoped CRUD, validate_site_in_organization
  rejects cross-tenant site_id refs at insert/update, queue_compute
  with PubSub broadcast on per-coverage and per-org topics.
- MSI Planet .ant parser + persistent_term registry loaded at boot
  from priv/antennas/. Two synthetic .ant fixtures (omni + 90deg
  sector) bundled.
- CoverageWorker on new :coverage Oban queue (concurrency 2). Job
  args carry organization_id; worker refuses to run on org mismatch.
  Stub flips status to "failed" with "compute not yet implemented"
  so the UI flow is exercisable.
- LiveViews: index (org-wide, live status updates), form (antenna
  picker grouped by manufacturer), show (status banners, params
  panel, map placeholder). Sidebar nav link added.
- Routes /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit
  under :require_authenticated_user_with_default_org.
- 41 new tests covering schema validation, .ant parsing, context
  CRUD with cross-org guards, Oban job enqueue.
2026-05-06 13:44:14 -05:00

180 lines
4.1 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
%{label: t("Coverage"), navigate: ~p"/coverage"},
%{label: @page_title}
]} />
<.header>
{@page_title}
<:subtitle>
{t(
"Configure the antenna and RF parameters. The compute pipeline will produce a heatmap on save."
)}
</:subtitle>
</.header>
<.form
for={@form}
id="coverage-form"
phx-change="validate"
phx-submit="save"
class="mt-6 space-y-8"
>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<%!-- Identity --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Identity")}
</h3>
<.input
field={@form[:name]}
type="text"
label={t("Name")}
placeholder={t("e.g. North sector 5 GHz")}
required
/>
<.input
field={@form[:site_id]}
type="select"
label={t("Site")}
prompt={t("Select a site")}
options={Enum.map(@sites, &{&1.name, &1.id})}
required
/>
<.input
field={@form[:antenna_slug]}
type="select"
label={t("Antenna")}
prompt={t("Select an antenna")}
options={@antenna_options}
required
/>
</div>
<%!-- Mounting --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Mounting")}
</h3>
<.input
field={@form[:height_agl_m]}
type="number"
label={t("Height above ground (m)")}
step="0.1"
min="1"
max="200"
required
/>
<.input
field={@form[:azimuth_deg]}
type="number"
label={t("Azimuth (°, true north)")}
step="0.1"
min="0"
max="360"
required
/>
<.input
field={@form[:downtilt_deg]}
type="number"
label={t("Downtilt (°, positive = down)")}
step="0.1"
min="-10"
max="30"
/>
</div>
<%!-- RF parameters --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("RF parameters")}
</h3>
<.input
field={@form[:frequency_mhz]}
type="number"
label={t("Frequency (MHz)")}
step="1"
min="700"
max="90000"
required
/>
<.input
field={@form[:eirp_dbm]}
type="number"
label={t("EIRP (dBm)")}
step="0.1"
min="0"
max="60"
required
/>
</div>
<%!-- Coverage extent --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Coverage extent")}
</h3>
<.input
field={@form[:radius_m]}
type="number"
label={t("Radius (m)")}
step="100"
min="500"
max="40000"
required
/>
<.input
field={@form[:cell_size_m]}
type="number"
label={t("Cell size (m)")}
step="1"
min="1"
max="50"
required
/>
<.input
field={@form[:receiver_height_m]}
type="number"
label={t("Receiver height (m)")}
step="0.1"
min="0.5"
max="100"
/>
<.input
field={@form[:rx_threshold_dbm]}
type="number"
label={t("RX threshold (dBm)")}
step="0.5"
min="-130"
max="0"
/>
</div>
</div>
<div class="flex justify-end gap-2">
<.link navigate={~p"/coverage"} class="btn btn-outline">
{t("Cancel")}
</.link>
<.button type="submit" variant="primary">
{if @live_action == :new, do: t("Create coverage"), else: t("Save changes")}
</.button>
</div>
</.form>
</Layouts.authenticated>