405 lines
13 KiB
Elixir
405 lines
13 KiB
Elixir
defmodule ToweropsWeb.ScheduleLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.OnCall
|
|
alias Towerops.OnCall.Layer
|
|
alias Towerops.OnCall.Override
|
|
alias Towerops.OnCall.Resolver
|
|
alias Towerops.OnCall.UserColor
|
|
alias Towerops.Organizations
|
|
|
|
@timeline_days 14
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
org_id = socket.assigns.current_scope.organization.id
|
|
schedule = OnCall.get_schedule!(id, org_id)
|
|
on_call = OnCall.who_is_on_call(schedule.id)
|
|
members = Organizations.list_organization_members(org_id)
|
|
users = Enum.map(members, & &1.user)
|
|
today = Date.utc_today()
|
|
timeline_start = Date.beginning_of_week(today, :sunday)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, schedule.name)
|
|
|> assign(:schedule, schedule)
|
|
|> assign(:on_call, on_call)
|
|
|> assign(:org_users, users)
|
|
# Pre-open the Add Layer form for freshly-created schedules so the
|
|
# new-schedule -> add-layers flow lands in a state ready to act on.
|
|
|> assign(:show_add_layer, schedule.layers == [])
|
|
|> assign(:show_add_override, false)
|
|
|> assign(:new_layer_members, [])
|
|
|> assign(:timeline_start, timeline_start)
|
|
|> assign_timeline(schedule, timeline_start)
|
|
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs())))
|
|
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))}
|
|
end
|
|
|
|
def handle_event("timeline_prev", _params, socket) do
|
|
new_start = Date.add(socket.assigns.timeline_start, -@timeline_days)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:timeline_start, new_start)
|
|
|> assign_timeline(socket.assigns.schedule, new_start)}
|
|
end
|
|
|
|
def handle_event("timeline_next", _params, socket) do
|
|
new_start = Date.add(socket.assigns.timeline_start, @timeline_days)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:timeline_start, new_start)
|
|
|> assign_timeline(socket.assigns.schedule, new_start)}
|
|
end
|
|
|
|
def handle_event("timeline_today", _params, socket) do
|
|
today = Date.utc_today()
|
|
new_start = Date.beginning_of_week(today, :sunday)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:timeline_start, new_start)
|
|
|> assign_timeline(socket.assigns.schedule, new_start)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete", _params, socket) do
|
|
case OnCall.delete_schedule(socket.assigns.schedule) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Schedule deleted"))
|
|
|> push_navigate(to: ~p"/schedules")}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Unable to delete schedule"))}
|
|
end
|
|
end
|
|
|
|
def handle_event("toggle_add_layer", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_add_layer, !socket.assigns.show_add_layer)
|
|
|> assign(:new_layer_members, [])}
|
|
end
|
|
|
|
def handle_event("new_layer_add_user", %{"user_id" => ""}, socket), do: {:noreply, socket}
|
|
|
|
def handle_event("new_layer_add_user", %{"user_id" => user_id}, socket) do
|
|
user = Enum.find(socket.assigns.org_users, &(&1.id == user_id))
|
|
|
|
if user && user.id not in Enum.map(socket.assigns.new_layer_members, & &1.id) do
|
|
{:noreply, assign(socket, :new_layer_members, socket.assigns.new_layer_members ++ [user])}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("new_layer_remove_user", %{"id" => user_id}, socket) do
|
|
members = Enum.reject(socket.assigns.new_layer_members, &(&1.id == user_id))
|
|
{:noreply, assign(socket, :new_layer_members, members)}
|
|
end
|
|
|
|
def handle_event("toggle_add_override", _params, socket) do
|
|
{:noreply, assign(socket, :show_add_override, !socket.assigns.show_add_override)}
|
|
end
|
|
|
|
def handle_event("validate_layer", %{"layer" => params}, socket) do
|
|
changeset =
|
|
%Layer{}
|
|
|> Layer.changeset(params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, :layer_form, to_form(changeset))}
|
|
end
|
|
|
|
def handle_event("save_layer", %{"layer" => params}, socket) do
|
|
schedule = socket.assigns.schedule
|
|
next_position = length(schedule.layers)
|
|
|
|
params =
|
|
params
|
|
|> Map.put("schedule_id", schedule.id)
|
|
|> Map.put("position", next_position)
|
|
|
|
case OnCall.create_layer(params) do
|
|
{:ok, layer} ->
|
|
socket.assigns.new_layer_members
|
|
|> Enum.with_index()
|
|
|> Enum.each(fn {user, idx} ->
|
|
OnCall.add_layer_member(%{layer_id: layer.id, user_id: user.id, position: idx})
|
|
end)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_add_layer, false)
|
|
|> assign(:new_layer_members, [])
|
|
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs())))
|
|
|> reload_schedule()}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :layer_form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
def handle_event("move_layer", %{"id" => id, "direction" => direction}, socket) when direction in ["up", "down"] do
|
|
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == id))
|
|
|
|
if layer do
|
|
{:ok, _} = OnCall.move_layer(layer, String.to_existing_atom(direction))
|
|
{:noreply, reload_schedule(socket)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("reorder_layers", %{"ordered_ids" => ordered_ids}, socket) when is_list(ordered_ids) do
|
|
case OnCall.reorder_layers(socket.assigns.schedule.id, ordered_ids) do
|
|
:ok -> {:noreply, reload_schedule(socket)}
|
|
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to reorder layers"))}
|
|
end
|
|
end
|
|
|
|
def handle_event("set_layer_restriction", params, socket) do
|
|
%{"layer_id" => layer_id, "restriction_type" => restriction_type} = params
|
|
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
|
|
|
|
if layer do
|
|
apply_layer_restriction(socket, layer, restriction_type, params)
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("delete_layer", %{"id" => layer_id}, socket) do
|
|
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
|
|
|
|
if layer do
|
|
case OnCall.delete_layer(layer) do
|
|
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
|
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete layer"))}
|
|
end
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("add_member", %{"user_id" => ""}, socket), do: {:noreply, socket}
|
|
|
|
def handle_event("add_member", %{"layer_id" => layer_id, "user_id" => user_id}, socket) do
|
|
layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id))
|
|
|
|
if layer do
|
|
next_position = length(layer.members)
|
|
|
|
case OnCall.add_layer_member(%{
|
|
layer_id: layer_id,
|
|
user_id: user_id,
|
|
position: next_position
|
|
}) do
|
|
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
|
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to add member"))}
|
|
end
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("remove_member", %{"id" => member_id}, socket) do
|
|
member =
|
|
socket.assigns.schedule.layers
|
|
|> Enum.flat_map(& &1.members)
|
|
|> Enum.find(&(&1.id == member_id))
|
|
|
|
if member do
|
|
case OnCall.remove_layer_member(member) do
|
|
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
|
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to remove member"))}
|
|
end
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("validate_override", %{"override" => params}, socket) do
|
|
changeset =
|
|
%Override{}
|
|
|> Override.changeset(params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, :override_form, to_form(changeset))}
|
|
end
|
|
|
|
def handle_event("save_override", %{"override" => params}, socket) do
|
|
schedule = socket.assigns.schedule
|
|
user_id = socket.assigns.current_scope.user.id
|
|
|
|
params =
|
|
params
|
|
|> Map.put("schedule_id", schedule.id)
|
|
|> Map.put("created_by_id", user_id)
|
|
|
|
case OnCall.create_override(params) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_add_override, false)
|
|
|> assign(:override_form, to_form(Override.changeset(%Override{}, %{})))
|
|
|> reload_schedule()}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :override_form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
def handle_event("delete_override", %{"id" => override_id}, socket) do
|
|
override = Enum.find(socket.assigns.schedule.overrides, &(&1.id == override_id))
|
|
|
|
if override do
|
|
case OnCall.delete_override(override) do
|
|
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
|
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to delete override"))}
|
|
end
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
defp display_name(%{first_name: first, last_name: last}) when first != nil and last != nil do
|
|
"#{first} #{last}"
|
|
end
|
|
|
|
defp display_name(%{email: email}), do: email
|
|
|
|
defp default_layer_attrs do
|
|
today = Date.utc_today()
|
|
|
|
%{
|
|
handoff_time: ~T[08:00:00],
|
|
start_date: DateTime.new!(today, ~T[08:00:00], "Etc/UTC")
|
|
}
|
|
end
|
|
|
|
defp reload_schedule(socket) do
|
|
schedule = OnCall.get_schedule!(socket.assigns.schedule.id)
|
|
on_call = OnCall.who_is_on_call(schedule.id)
|
|
|
|
socket
|
|
|> assign(:schedule, schedule)
|
|
|> assign(:on_call, on_call)
|
|
|> assign_timeline(schedule, socket.assigns.timeline_start)
|
|
end
|
|
|
|
defp assign_timeline(socket, schedule, start_date) do
|
|
end_date = Date.add(start_date, @timeline_days - 1)
|
|
days = start_date |> Date.range(end_date) |> Enum.to_list()
|
|
|
|
layer_timelines =
|
|
schedule.layers
|
|
|> Enum.sort_by(& &1.position)
|
|
|> Enum.map(fn layer ->
|
|
spans = build_layer_spans(layer, start_date, @timeline_days)
|
|
%{layer: layer, spans: spans}
|
|
end)
|
|
|
|
final_spans = build_final_spans(schedule, start_date, @timeline_days)
|
|
|
|
all_users =
|
|
(Enum.flat_map(layer_timelines, fn lt -> Enum.map(lt.spans, & &1.user) end) ++
|
|
Enum.map(final_spans, & &1.user))
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.uniq_by(& &1.id)
|
|
|
|
user_colors = Map.new(all_users, fn user -> {user.id, UserColor.class_for(user)} end)
|
|
|
|
socket
|
|
|> assign(:timeline_days, days)
|
|
|> assign(:timeline_end, end_date)
|
|
|> assign(:layer_timelines, layer_timelines)
|
|
|> assign(:final_spans, final_spans)
|
|
|> assign(:user_colors, user_colors)
|
|
end
|
|
|
|
defp build_layer_spans(layer, start_date, num_days) do
|
|
# Resolve a single-layer "schedule" so we get per-layer coverage
|
|
# without overrides bleeding in. The Resolver only reads :layers and
|
|
# :overrides off the struct, so a stub keyword-style struct is fine.
|
|
layer_schedule = %Towerops.OnCall.Schedule{layers: [layer], overrides: []}
|
|
segments_to_spans(layer_schedule, start_date, num_days)
|
|
end
|
|
|
|
defp build_final_spans(schedule, start_date, num_days) do
|
|
segments_to_spans(schedule, start_date, num_days)
|
|
end
|
|
|
|
defp segments_to_spans(schedule, start_date, num_days) do
|
|
total_hours = num_days * 24
|
|
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
|
|
end_at = DateTime.add(start_at, total_hours * 3600, :second)
|
|
|
|
schedule
|
|
|> Resolver.resolve_range(start_at, end_at)
|
|
|> Enum.map(fn segment ->
|
|
start_offset = max(div(DateTime.diff(segment.start_at, start_at, :second), 3600), 0)
|
|
end_offset = min(div(DateTime.diff(segment.end_at, start_at, :second), 3600), total_hours)
|
|
duration = max(end_offset - start_offset, 1)
|
|
|
|
%{
|
|
user: segment.user,
|
|
start_offset: start_offset,
|
|
duration: duration,
|
|
total_hours: total_hours
|
|
}
|
|
end)
|
|
end
|
|
|
|
# --- Layer restriction helpers ---
|
|
|
|
defp apply_layer_restriction(socket, layer, restriction_type, params) do
|
|
restrictions = build_restrictions(restriction_type, params)
|
|
|
|
result =
|
|
if is_nil(restrictions) do
|
|
OnCall.clear_layer_restrictions(layer)
|
|
else
|
|
OnCall.update_layer_restrictions(layer, %{
|
|
restriction_type: restriction_type,
|
|
restrictions: restrictions
|
|
})
|
|
end
|
|
|
|
case result do
|
|
{:ok, _} -> {:noreply, reload_schedule(socket)}
|
|
{:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to update restrictions"))}
|
|
end
|
|
end
|
|
|
|
defp build_restrictions("", _params), do: nil
|
|
|
|
defp build_restrictions("time_of_day", params) do
|
|
%{
|
|
"start_time" => params["start_time"] || "09:00",
|
|
"end_time" => params["end_time"] || "17:00"
|
|
}
|
|
end
|
|
|
|
defp build_restrictions("time_of_week", params) do
|
|
%{
|
|
"start_time" => params["start_time"] || "09:00",
|
|
"end_time" => params["end_time"] || "17:00",
|
|
"days" => normalize_days(params["days"])
|
|
}
|
|
end
|
|
|
|
defp build_restrictions(_, _params), do: nil
|
|
|
|
defp normalize_days(nil), do: []
|
|
defp normalize_days(list) when is_list(list), do: list
|
|
defp normalize_days(map) when is_map(map), do: map |> Map.keys() |> Enum.filter(&(map[&1] == "true"))
|
|
defp normalize_days(other) when is_binary(other), do: [other]
|
|
defp normalize_days(_), do: []
|
|
end
|