towerops/lib/towerops_web/live/coverage_live/form.html.heex
Graham McIntire 84d4fccb29 feat: optionally attach a coverage to a device at the chosen site
After picking a site in the coverage form, a new optional dropdown
lets the user attach the coverage to one of the devices at that site.
The dropdown reloads on every site change. If the site has no devices
yet, a hint replaces it.

Schema: nullable device_id FK on coverages (on_delete: nilify_all so
deleting a device leaves the coverage standing). Filtered by both
site_id and organization_id when loading the dropdown options.

Show page lists the attached device by name (or IP) when set.
2026-05-06 14:49:32 -05:00

197 lines
5.2 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 radio and antenna. Coverage will be computed in the background once you save."
)}
</:subtitle>
<:actions>
<span class="inline-flex items-center px-3 py-1.5 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 text-sm">
<span class="text-blue-700 dark:text-blue-300 font-medium mr-2">EIRP:</span>
<span class="text-blue-900 dark:text-blue-100 font-mono">
{Float.round(@computed_eirp, 1)} dBm
</span>
</span>
</:actions>
</.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
:if={@devices != []}
field={@form[:device_id]}
type="select"
label={t("Attach to device (optional)")}
prompt={t("No specific device")}
options={Enum.map(@devices, &{device_label(&1), &1.id})}
/>
<p
:if={@form[:site_id].value not in [nil, ""] and @devices == []}
class="text-xs text-gray-500 dark:text-gray-400"
>
{t("This site has no devices yet — coverage will not be attached to a specific device.")}
</p>
<.input
field={@form[:antenna_slug]}
type="select"
label={t("Antenna")}
prompt={t("Select an antenna")}
options={@antenna_options}
required
/>
</div>
<%!-- Radio --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Radio")}
</h3>
<.input
field={@form[:frequency_ghz]}
type="number"
label={t("Frequency (GHz)")}
step="0.001"
min="0.7"
max="90"
required
/>
<.input
field={@form[:tx_power_dbm]}
type="number"
label={t("TX power (dBm)")}
step="0.1"
min="-10"
max="50"
required
/>
<.input
field={@form[:radius_mi]}
type="number"
label={t("Range (mi)")}
step="0.1"
min="0.3"
max="25"
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_ft]}
type="number"
label={t("Height above ground (ft)")}
step="1"
min="3"
max="650"
required
/>
<.input
field={@form[:azimuth_deg]}
type="number"
label={t("Azimuth (°, true north)")}
step="1"
min="0"
max="360"
required
/>
<.input
field={@form[:downtilt_deg]}
type="number"
label={t("Tilt (°, positive = down)")}
step="0.1"
min="-10"
max="30"
/>
</div>
<%!-- Foliage --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Environment")}
</h3>
<div>
<label
for="coverage_foliage_tuning"
class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"
>
{t("Foliage tuning")}: {Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
</label>
<input
type="range"
id="coverage_foliage_tuning"
name="coverage[foliage_tuning]"
value={Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
min="0"
max="100"
step="1"
class="w-full"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("Higher values reduce predicted signal in wooded areas.")}
</p>
</div>
</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>