enhance: schedule show with PagerDuty-style timeline and UX improvements
- Add 2-week visual timeline with per-layer and final schedule views - Color-coded user spans with today marker and prev/next navigation - Inline user picker when adding new layers (auto-add on select) - Auto-add members on select for existing layers (no Add button) - Display user names (first last) instead of emails throughout - Default handoff time to 8:00 AM and start date to today
This commit is contained in:
parent
0cd2ed3567
commit
61368e5c5e
2 changed files with 388 additions and 27 deletions
|
|
@ -5,8 +5,15 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
alias Towerops.OnCall
|
||||
alias Towerops.OnCall.Layer
|
||||
alias Towerops.OnCall.Override
|
||||
alias Towerops.OnCall.Resolver
|
||||
alias Towerops.Organizations
|
||||
|
||||
@timeline_days 14
|
||||
@user_colors ~w(
|
||||
bg-blue-500 bg-purple-500 bg-green-500 bg-orange-500 bg-pink-500
|
||||
bg-teal-500 bg-indigo-500 bg-red-500 bg-cyan-500 bg-amber-500
|
||||
)
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
schedule = OnCall.get_schedule!(id)
|
||||
|
|
@ -14,6 +21,8 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
org_id = socket.assigns.current_scope.organization.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
|
||||
|
|
@ -25,10 +34,41 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
|> assign(:org_users, users)
|
||||
|> assign(:show_add_layer, false)
|
||||
|> assign(:show_add_override, false)
|
||||
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
|
||||
|> 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
|
||||
|
|
@ -44,7 +84,27 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
end
|
||||
|
||||
def handle_event("toggle_add_layer", _params, socket) do
|
||||
{:noreply, assign(socket, :show_add_layer, !socket.assigns.show_add_layer)}
|
||||
{: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
|
||||
|
|
@ -70,11 +130,18 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
|> Map.put("position", next_position)
|
||||
|
||||
case OnCall.create_layer(params) do
|
||||
{:ok, _layer} ->
|
||||
{: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(:layer_form, to_form(Layer.changeset(%Layer{}, %{})))
|
||||
|> assign(:new_layer_members, [])
|
||||
|> assign(:layer_form, to_form(Layer.changeset(%Layer{}, default_layer_attrs())))
|
||||
|> reload_schedule()}
|
||||
|
||||
{:error, changeset} ->
|
||||
|
|
@ -95,6 +162,8 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
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))
|
||||
|
||||
|
|
@ -174,6 +243,21 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
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)
|
||||
|
|
@ -181,5 +265,106 @@ defmodule ToweropsWeb.ScheduleLive.Show do
|
|||
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 =
|
||||
all_users
|
||||
|> Enum.with_index()
|
||||
|> Map.new(fn {user, idx} -> {user.id, Enum.at(@user_colors, rem(idx, length(@user_colors)))} 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
|
||||
start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC")
|
||||
total_hours = num_days * 24
|
||||
|
||||
0..(total_hours - 1)
|
||||
|> Enum.map(fn hour_offset ->
|
||||
dt = DateTime.add(start_dt, hour_offset * 3600, :second)
|
||||
user = resolve_layer_only(layer, dt)
|
||||
{hour_offset, user}
|
||||
end)
|
||||
|> Enum.chunk_by(fn {_, user} -> user && user.id end)
|
||||
|> Enum.map(fn chunk ->
|
||||
{start_offset, user} = List.first(chunk)
|
||||
duration = length(chunk)
|
||||
|
||||
%{
|
||||
user: user,
|
||||
start_offset: start_offset,
|
||||
duration: duration,
|
||||
total_hours: total_hours
|
||||
}
|
||||
end)
|
||||
|> Enum.reject(fn span -> is_nil(span.user) end)
|
||||
end
|
||||
|
||||
defp build_final_spans(schedule, start_date, num_days) do
|
||||
start_dt = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC")
|
||||
total_hours = num_days * 24
|
||||
|
||||
0..(total_hours - 1)
|
||||
|> Enum.map(fn hour_offset ->
|
||||
dt = DateTime.add(start_dt, hour_offset * 3600, :second)
|
||||
user = Resolver.resolve(schedule, dt)
|
||||
{hour_offset, user}
|
||||
end)
|
||||
|> Enum.chunk_by(fn {_, user} -> user && user.id end)
|
||||
|> Enum.map(fn chunk ->
|
||||
{start_offset, user} = List.first(chunk)
|
||||
duration = length(chunk)
|
||||
|
||||
%{
|
||||
user: user,
|
||||
start_offset: start_offset,
|
||||
duration: duration,
|
||||
total_hours: total_hours
|
||||
}
|
||||
end)
|
||||
|> Enum.reject(fn span -> is_nil(span.user) end)
|
||||
end
|
||||
|
||||
defp resolve_layer_only(layer, datetime) do
|
||||
with true <- DateTime.compare(datetime, layer.start_date) in [:gt, :eq],
|
||||
members = Enum.sort_by(layer.members, & &1.position),
|
||||
true <- members != [] do
|
||||
elapsed = DateTime.diff(datetime, layer.start_date, :second)
|
||||
rotation_secs = rotation_seconds(layer)
|
||||
position = elapsed |> div(rotation_secs) |> rem(length(members))
|
||||
Enum.at(members, position).user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp rotation_seconds(%{rotation_type: "daily", rotation_interval: i}), do: i * 86_400
|
||||
defp rotation_seconds(%{rotation_type: "weekly", rotation_interval: i}), do: i * 604_800
|
||||
defp rotation_seconds(%{rotation_interval: i}), do: i * 86_400
|
||||
end
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@
|
|||
{t("Currently On-Call")}
|
||||
</p>
|
||||
<%= if @on_call do %>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{@on_call.email}</p>
|
||||
<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")}
|
||||
|
|
@ -71,6 +73,149 @@
|
|||
</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="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 mb-4">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<%!-- Final Schedule --%>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<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">
|
||||
<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>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Layers --%>
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
|
@ -127,6 +272,45 @@
|
|||
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")}
|
||||
|
|
@ -192,7 +376,7 @@
|
|||
<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">
|
||||
{member.user.email}
|
||||
{display_name(member.user)}
|
||||
<button
|
||||
phx-click="remove_member"
|
||||
phx-value-id={member.id}
|
||||
|
|
@ -209,25 +393,17 @@
|
|||
<% 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 %>
|
||||
<form phx-submit="add_member" class="flex items-center gap-2">
|
||||
<input type="hidden" name="layer_id" value={layer.id} />
|
||||
<select
|
||||
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-xs py-1.5"
|
||||
>
|
||||
<option value="">{t("Select a member...")}</option>
|
||||
<%= for user <- available_users do %>
|
||||
<option value={user.id}>{user.email}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
|
||||
{t("Add")}
|
||||
</button>
|
||||
</form>
|
||||
<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>
|
||||
|
|
@ -257,7 +433,7 @@
|
|||
field={@override_form[:user_id]}
|
||||
type="select"
|
||||
label={t("User")}
|
||||
options={Enum.map(@org_users, &{&1.email, &1.id})}
|
||||
options={Enum.map(@org_users, &{display_name(&1), &1.id})}
|
||||
prompt={t("Select user...")}
|
||||
/>
|
||||
<.input
|
||||
|
|
@ -314,7 +490,7 @@
|
|||
<%= for override <- @schedule.overrides do %>
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
{override.user.email}
|
||||
{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")}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue