towerops/lib/towerops_web/live/schedule_live/show.html.heex
Graham McIntire 19abd5508a
ux: improve mobile responsiveness across all pages
- Hide less-critical table columns on small screens (IP, subs, MRR, etc.)
- Add overflow-x-auto wrappers on tables to prevent horizontal scroll
- Fix <.header> component to flex-wrap action buttons on mobile
- Make page action button labels icon-only on small screens
- Add min-w-0 on grid children to prevent viewport overflow
- Right-align device detail dt/dd values consistently
- Fix schedule timeline cards with overflow-x-auto wrappers
2026-03-12 16:49:59 -05:00

522 lines
22 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="schedules"
>
<div class="flex flex-wrap items-start justify-between gap-3 mb-6">
<div class="flex items-center gap-3 min-w-0">
<.link
navigate={~p"/schedules"}
class="shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-arrow-left" class="h-5 w-5" />
</.link>
<div class="min-w-0">
<h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">{@schedule.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{@schedule.timezone}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<.link
navigate={~p"/schedules/#{@schedule.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-pencil" class="h-4 w-4" />
<span class="hidden sm:inline">{t("Edit")}</span>
</.link>
<button
phx-click="delete"
data-confirm={t("Are you sure you want to delete this schedule?")}
class="inline-flex items-center gap-2 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
<span class="hidden sm:inline">{t("Delete")}</span>
</button>
</div>
</div>
<%= if @schedule.description do %>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">{@schedule.description}</p>
<% end %>
<%!-- On-Call Status --%>
<div class="mb-8 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<div class="flex items-center gap-3">
<div class={[
"h-10 w-10 rounded-full flex items-center justify-center",
@on_call && "bg-green-100 dark:bg-green-900/40",
!@on_call && "bg-gray-100 dark:bg-gray-800"
]}>
<.icon
name="hero-user"
class={[
"h-5 w-5",
@on_call && "text-green-600 dark:text-green-400",
!@on_call && "text-gray-400 dark:text-gray-500"
]}
/>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Currently On-Call")}
</p>
<%= if @on_call do %>
<p class="text-sm font-semibold text-gray-900 dark:text-white">
{display_name(@on_call)}
</p>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No one is currently on-call")}
</p>
<% end %>
</div>
</div>
</div>
<%!-- Timeline --%>
<div class="mb-8">
<%!-- Timeline navigation --%>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<button
phx-click="timeline_today"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t("Today")}
</button>
<button
phx-click="timeline_prev"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<.icon name="hero-chevron-left" class="h-4 w-4" />
</button>
<button
phx-click="timeline_next"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<.icon name="hero-chevron-right" class="h-4 w-4" />
</button>
<span class="text-sm font-semibold text-gray-900 dark:text-white ml-2">
{Calendar.strftime(@timeline_start, "%b %-d")} {Calendar.strftime(
@timeline_end,
"%b %-d, %Y"
)}
</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
{t("2 Weeks")}
</span>
</div>
<%!-- Layer timelines --%>
<%= if @layer_timelines != [] do %>
<div class="overflow-x-auto">
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 mb-4 min-w-[320px]">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Configuration Layers")}
</h3>
<%= for lt <- @layer_timelines do %>
<div class="mb-4 last:mb-0">
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<div class="flex-1 text-center border-l first:border-l-0 border-gray-200 dark:border-gray-700">
<span class="text-[10px] text-gray-400 dark:text-gray-500">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
</div>
</div>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0 pr-2 text-right">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{lt.layer.name}
</span>
</div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- lt.spans do %>
<div
class={[
"absolute inset-y-0 flex items-center px-1 overflow-hidden",
@user_colors[span.user.id] || "bg-gray-400"
]}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
>
<span class="text-[10px] font-medium text-white truncate">
{display_name(span.user)}
</span>
</div>
<% end %>
<%!-- Today marker --%>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %>
<div
class="absolute inset-y-0 w-px bg-red-500 z-10"
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Final Schedule --%>
<div class="overflow-x-auto">
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 min-w-[320px]">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
{t("Final Schedule")}
</h3>
<%!-- Day headers --%>
<div class="flex mb-1">
<div class="w-28 shrink-0"></div>
<div class="flex-1 flex">
<%= for day <- @timeline_days do %>
<div class="flex-1 text-center border-l first:border-l-0 border-gray-200 dark:border-gray-700">
<span class="text-[10px] text-gray-400 dark:text-gray-500">
{Calendar.strftime(day, "%-d %a")}
</span>
</div>
<% end %>
</div>
</div>
<%!-- Timeline bar --%>
<div class="flex items-center">
<div class="w-28 shrink-0"></div>
<div class="flex-1 relative h-8 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden">
<%= for span <- @final_spans do %>
<div
class={[
"absolute inset-y-0 flex items-center px-1 overflow-hidden",
@user_colors[span.user.id] || "bg-gray-400"
]}
style={"left: #{span.start_offset / span.total_hours * 100}%; width: #{span.duration / span.total_hours * 100}%;"}
>
<span class="text-[10px] font-medium text-white truncate">
{display_name(span.user)}
</span>
</div>
<% end %>
<%!-- Today marker --%>
<% today_offset = Date.diff(Date.utc_today(), @timeline_start) %>
<%= if today_offset >= 0 and today_offset < @timeline_days do %>
<div
class="absolute inset-y-0 w-px bg-red-500 z-10"
style={"left: #{(today_offset * 24 + DateTime.utc_now().hour) / (length(@timeline_days) * 24) * 100}%;"}
>
</div>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>
<%!-- Layers --%>
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Layers")}</h2>
<button
phx-click="toggle_add_layer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("Add Layer")}
</button>
</div>
<%= if @show_add_layer do %>
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
<.form for={@layer_form} phx-change="validate_layer" phx-submit="save_layer">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<.input
field={@layer_form[:name]}
type="text"
label={t("Layer Name")}
placeholder="Layer 1"
/>
<.input
field={@layer_form[:rotation_type]}
type="select"
label={t("Rotation Type")}
options={[{t("Daily"), "daily"}, {t("Weekly"), "weekly"}, {t("Custom"), "custom"}]}
/>
<.input
field={@layer_form[:rotation_interval]}
type="number"
label={t("Rotation Interval")}
min="1"
/>
<.input field={@layer_form[:handoff_time]} type="time" label={t("Handoff Time")} />
<.input
field={@layer_form[:handoff_day]}
type="select"
label={t("Handoff Day")}
options={[
{t("Sunday"), "0"},
{t("Monday"), "1"},
{t("Tuesday"), "2"},
{t("Wednesday"), "3"},
{t("Thursday"), "4"},
{t("Friday"), "5"},
{t("Saturday"), "6"}
]}
/>
<.input
field={@layer_form[:start_date]}
type="datetime-local"
label={t("Start Date")}
/>
</div>
<%!-- User selection for new layer --%>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("Members")}
</label>
<%= if @new_layer_members != [] do %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for user <- @new_layer_members do %>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/40 pl-2.5 pr-1 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">
{display_name(user)}
<button
type="button"
phx-click="new_layer_remove_user"
phx-value-id={user.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<% new_member_ids = Enum.map(@new_layer_members, & &1.id) %>
<% available = Enum.reject(@org_users, &(&1.id in new_member_ids)) %>
<%= unless Enum.empty?(available) do %>
<select
phx-change="new_layer_add_user"
name="user_id"
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
>
<option value="">{t("Select a user")}</option>
<%= for user <- available do %>
<option value={user.id}>{display_name(user)}</option>
<% end %>
</select>
<% end %>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Layer")}
</.button>
<button
type="button"
phx-click="toggle_add_layer"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
</div>
</.form>
</div>
<% end %>
<%= if Enum.empty?(@schedule.layers) do %>
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No layers configured. Add a layer to define rotation coverage.")}
</p>
</div>
<% else %>
<div class="space-y-4">
<%= for layer <- Enum.sort_by(@schedule.layers, & &1.position) do %>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{layer.name}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval}
<%= case layer.rotation_type do %>
<% "daily" -> %>
{ngettext("day", "days", layer.rotation_interval)}
<% "weekly" -> %>
{ngettext("week", "weeks", layer.rotation_interval)}
<% _ -> %>
{ngettext("day", "days", layer.rotation_interval)}
<% end %>
· Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")}
</p>
</div>
<button
phx-click="delete_layer"
phx-value-id={layer.id}
data-confirm={t("Are you sure you want to delete this layer?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
<%!-- Members --%>
<div class="mb-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Members")}
</p>
<%= if Enum.empty?(layer.members) do %>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t("No members yet")}
</p>
<% else %>
<div class="flex flex-wrap gap-2 mb-2">
<%= for member <- Enum.sort_by(layer.members, & &1.position) do %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{display_name(member.user)}
<button
phx-click="remove_member"
phx-value-id={member.id}
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</span>
<% end %>
</div>
<% end %>
<%!-- Add member dropdown --%>
<% existing_user_ids = Enum.map(layer.members, & &1.user_id) %>
<% available_users = Enum.reject(@org_users, &(&1.id in existing_user_ids)) %>
<%= unless Enum.empty?(available_users) do %>
<select
phx-change="add_member"
name="user_id"
phx-value-layer_id={layer.id}
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm py-1.5"
>
<option value="">{t("Select a user")}</option>
<%= for user <- available_users do %>
<option value={user.id}>{display_name(user)}</option>
<% end %>
</select>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<%!-- Overrides --%>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Overrides")}</h2>
<button
phx-click="toggle_add_override"
class="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("Add Override")}
</button>
</div>
<%= if @show_add_override do %>
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-4">
<.form for={@override_form} phx-change="validate_override" phx-submit="save_override">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<.input
field={@override_form[:user_id]}
type="select"
label={t("User")}
options={Enum.map(@org_users, &{display_name(&1), &1.id})}
prompt={t("Select user...")}
/>
<.input
field={@override_form[:start_time]}
type="datetime-local"
label={t("Start Time")}
/>
<.input
field={@override_form[:end_time]}
type="datetime-local"
label={t("End Time")}
/>
</div>
<div class="flex items-center gap-2">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Add Override")}
</.button>
<button
type="button"
phx-click="toggle_add_override"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
{t("Cancel")}
</button>
</div>
</.form>
</div>
<% end %>
<%= if Enum.empty?(@schedule.overrides) do %>
<div class="rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{t("No overrides configured.")}
</p>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("User")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Start")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("End")}
</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for override <- @schedule.overrides do %>
<tr>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
{display_name(override.user)}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{Calendar.strftime(override.start_time, "%Y-%m-%d %H:%M")}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{Calendar.strftime(override.end_time, "%Y-%m-%d %H:%M")}
</td>
<td class="px-4 py-3 text-right">
<button
phx-click="delete_override"
phx-value-id={override.id}
data-confirm={t("Are you sure?")}
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</Layouts.authenticated>