towerops/lib/towerops_web/live/schedule_live/show.html.heex

643 lines
27 KiB
Text
Raw Permalink 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"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<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
id="layer-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 %>
<% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %>
<% layer_count = length(sorted_layers) %>
<div
id="layer-list"
class="space-y-4"
phx-hook="SortableList"
data-event="reorder_layers"
>
<%= for {layer, idx} <- Enum.with_index(sorted_layers) do %>
<div
data-id={layer.id}
draggable="true"
class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 cursor-move"
>
<div class="flex items-start justify-between mb-3 gap-3">
<div class="flex items-start gap-2 min-w-0">
<.icon name="hero-bars-3" class="h-4 w-4 text-gray-400 mt-0.5 shrink-0" />
<div class="min-w-0">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{layer.name}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{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>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
phx-click="move_layer"
phx-value-id={layer.id}
phx-value-direction="up"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
disabled={idx == 0}
title={t("Move up")}
>
<.icon name="hero-arrow-up" class="h-4 w-4" />
</button>
<button
phx-click="move_layer"
phx-value-id={layer.id}
phx-value-direction="down"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
disabled={idx == layer_count - 1}
title={t("Move down")}
>
<.icon name="hero-arrow-down" class="h-4 w-4" />
</button>
<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 ml-1"
title={t("Delete")}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</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>
<%!-- Restrictions editor --%>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-white/5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("Restrict on-call shifts")}
</p>
<form
id={"layer-restriction-form-#{layer.id}"}
phx-change="set_layer_restriction"
phx-submit="set_layer_restriction"
class="space-y-2"
>
<input type="hidden" name="layer_id" value={layer.id} />
<select
name="restriction_type"
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
>
<option value="" selected={is_nil(layer.restriction_type)}>
{t("Always on call (no restriction)")}
</option>
<option value="time_of_day" selected={layer.restriction_type == "time_of_day"}>
{t("Time of day")}
</option>
<option value="time_of_week" selected={layer.restriction_type == "time_of_week"}>
{t("Time of week")}
</option>
</select>
<%= if layer.restriction_type in ["time_of_day", "time_of_week"] do %>
<div class="flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
<span>{t("From")}</span>
<input
type="time"
name="start_time"
value={layer.restrictions["start_time"] || "09:00"}
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
/>
<span>{t("to")}</span>
<input
type="time"
name="end_time"
value={layer.restrictions["end_time"] || "17:00"}
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
/>
</div>
<% end %>
<%= if layer.restriction_type == "time_of_week" do %>
<div class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-300">
<% selected_days =
((layer.restrictions || %{})["days"] || [])
|> Enum.map(fn d ->
case d do
d when is_integer(d) -> d
d when is_binary(d) -> String.to_integer(d)
end
end) %>
<%= for {label, day} <- [{"Mon", 1}, {"Tue", 2}, {"Wed", 3}, {"Thu", 4}, {"Fri", 5}, {"Sat", 6}, {"Sun", 7}] do %>
<label class="inline-flex items-center gap-1">
<input
type="checkbox"
name={"days[#{day}]"}
value="true"
checked={day in selected_days}
class="rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800"
/>
<span>{label}</span>
</label>
<% end %>
</div>
<% end %>
</form>
</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
id="override-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>