Add maintenance windows LiveView UI

- Index page with All/Active/Upcoming/Past filter tabs
- Show page with active banner, edit/delete actions
- Form page with scope picker (org-wide/site/device), datetime fields
- Nav link added to desktop and mobile nav
This commit is contained in:
Graham McIntire 2026-02-16 10:20:16 -06:00
parent 3f2b2a5d5e
commit 0f9fac76d9
2 changed files with 57 additions and 28 deletions

View file

@ -280,6 +280,12 @@ defmodule ToweropsWeb.Layouts do
>
{t("Alerts")}
</.nav_link>
<.nav_link
navigate={~p"/maintenance"}
active={@active_page == "maintenance"}
>
{t("Maintenance")}
</.nav_link>
<!-- Separator -->
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
@ -559,6 +565,9 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/alerts"} active={@active_page == "alerts"}>
<.icon name="hero-bell" class="size-5" /> {t("Alerts")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
</.mobile_nav_link>
</div>
<!-- Secondary nav -->

View file

@ -37,7 +37,9 @@
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
placeholder={t("e.g., Tower firmware upgrade")}
/>
<%= for {msg, _} <- (@changeset.errors[:name] || []) do %><p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p><% end %>
<%= for {msg, _} <- (@changeset.errors[:name] || []) do %>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p>
<% end %>
</div>
<%!-- Reason --%>
@ -58,20 +60,42 @@
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t("Scope")}</label>
<div class="flex items-center gap-4 mb-3">
<%= for {label, value} <- [{t("Org-wide"), "org"}, {t("Site"), "site"}, {t("Device"), "device"}] do %>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scope_type"
value={value}
checked={@scope_type == value}
phx-click="change_scope"
phx-value-scope_type={value}
class="text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{label}</span>
</label>
<% end %>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scope_type"
value="org"
checked={@scope_type == "org"}
phx-click="change_scope"
phx-value-scope_type="org"
class="text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Org-wide")}</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scope_type"
value="site"
checked={@scope_type == "site"}
phx-click="change_scope"
phx-value-scope_type="site"
class="text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Site")}</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scope_type"
value="device"
checked={@scope_type == "device"}
phx-click="change_scope"
phx-value-scope_type="device"
class="text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Device")}</span>
</label>
</div>
<%= if @scope_type == "site" do %>
@ -117,7 +141,9 @@
required
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
/>
<%= for {msg, _} <- (@changeset.errors[:starts_at] || []) do %><p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p><% end %>
<%= for {msg, _} <- (@changeset.errors[:starts_at] || []) do %>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p>
<% end %>
</div>
<div>
<label for="maintenance_window_ends_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@ -131,18 +157,16 @@
required
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
/>
<%= for {msg, _} <- (@changeset.errors[:ends_at] || []) do %><p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p><% end %>
<%= for {msg, _} <- (@changeset.errors[:ends_at] || []) do %>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p>
<% end %>
</div>
</div>
<%!-- Suppress Alerts --%>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="hidden"
name="maintenance_window[suppress_alerts]"
value="false"
/>
<input type="hidden" name="maintenance_window[suppress_alerts]" value="false" />
<input
type="checkbox"
name="maintenance_window[suppress_alerts]"
@ -157,11 +181,7 @@
<%!-- Recurring --%>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="hidden"
name="maintenance_window[recurring]"
value="false"
/>
<input type="hidden" name="maintenance_window[recurring]" value="false" />
<input
type="checkbox"
name="maintenance_window[recurring]"
@ -173,7 +193,7 @@
</label>
</div>
<%!-- Recurrence Rule (shown when recurring) --%>
<%!-- Recurrence Rule --%>
<%= if Ecto.Changeset.get_field(@changeset, :recurring) == true do %>
<div>
<label for="maintenance_window_recurrence_rule" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">