Current section

Files

Jump to
phoenix_kit_projects lib phoenix_kit_projects web project_show_live.ex
Raw

lib/phoenix_kit_projects/web/project_show_live.ex

defmodule PhoenixKitProjects.Web.ProjectShowLive do
@moduledoc """
Show a project with a vertical timeline of assignments.
Supports inline status changes, duration editing, dependency
management, and tracks who completed each task.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitProjects.Gettext
use PhoenixKitProjects.Web.Components
alias PhoenixKitProjects.{Activity, L10n, Paths, Projects, Statuses}
alias PhoenixKitProjects.PubSub, as: ProjectsPubSub
alias PhoenixKitProjects.Schemas.{Assignment, Project}
alias PhoenixKitProjects.Schemas.Task, as: TaskSchema
alias PhoenixKitProjects.Web.Components.AssignmentStatusBadge
alias PhoenixKitProjects.Web.Helpers, as: WebHelpers
require Logger
# Default wrapper class for the standalone admin page. Embedders can
# override via `live_render(... session: %{"wrapper_class" => "..."})`
# to drop `mx-auto max-w-4xl` and fill a wider host layout.
@default_wrapper_class "flex flex-col w-full px-4 py-6 gap-4"
# Embedded entry: when nested via `live_render`, params arrives as
# `:not_mounted_at_router` and `session` carries the project id (plus
# any `wrapper_class` override). Delegate to the router clause so the
# mount logic stays single-sourced.
@impl true
def mount(:not_mounted_at_router, %{"id" => id} = session, socket) do
WebHelpers.maybe_put_locale(session)
mount(%{"id" => id}, session, socket)
end
# Fail-closed: emit-session lacking `"id"` lands here. Without this
# clause the mount/3 dispatch raises `FunctionClauseError`. Render
# placeholders + flash + close so the host pops the modal.
#
# `maybe_put_locale/1` first so the "Project not found." flash
# renders in the host's locale — matches every other LV's mount/3
# contract and avoids an English flash on a misrouted modal.
def mount(:not_mounted_at_router, session, socket) do
WebHelpers.maybe_put_locale(session)
socket = WebHelpers.assign_embed_state(socket, session)
{:ok,
socket
|> assign(
page_title: "",
project: %Project{},
is_template: false,
wrapper_class: Map.get(session, "wrapper_class", @default_wrapper_class),
assignments: [],
deps_by_assignment: %{},
total_tasks: 0,
done_tasks: 0,
progress_pct: 0,
schedule: nil,
editing_duration_uuid: nil,
start_modal_open: false,
start_form: to_form(%{"start_at" => default_start_at_local()}),
comments_resource: nil,
comments_enabled: false,
project_comment_count: 0,
assignment_comment_counts: %{},
statuses_available: false,
current_status: nil,
status_options: [],
expanded_subprojects: MapSet.new(),
subproject_summaries: %{},
subproject_child_tasks: %{}
)
|> put_flash(:error, gettext("Project not found."))
|> WebHelpers.close_or_navigate(Paths.projects())}
end
def mount(%{"id" => id}, session, socket) do
# Locale first so any error flashes / placeholders render in the
# right language. Embed state second so the not-found path can
# honor emit mode (broadcasting `:closed` instead of push_navigate).
WebHelpers.maybe_put_locale(session)
socket = WebHelpers.assign_embed_state(socket, session)
# `get_project/1` stays in mount/3 because the not-found path
# has to redirect before render, and the per-project PubSub
# topic needs the project.uuid to subscribe to. The heavier
# assignment/comment loads sit at the tail of mount/3 (not
# `handle_params/3`) because Phoenix LiveView refuses to mount a
# LV that exports `handle_params/3` outside a router live route,
# which would block embedding via `live_render`.
case Projects.get_project_with_assignee(id) do
nil ->
{:ok,
socket
|> assign(
page_title: "",
project: %Project{},
is_template: false,
wrapper_class: @default_wrapper_class,
assignments: [],
deps_by_assignment: %{},
total_tasks: 0,
done_tasks: 0,
progress_pct: 0,
schedule: nil,
editing_duration_uuid: nil,
start_modal_open: false,
start_form: to_form(%{"start_at" => default_start_at_local()}),
comments_resource: nil,
comments_enabled: false,
project_comment_count: 0,
assignment_comment_counts: %{},
statuses_available: false,
current_status: nil,
status_options: [],
expanded_subprojects: MapSet.new(),
subproject_summaries: %{},
subproject_child_tasks: %{}
)
|> put_flash(:error, gettext("Project not found."))
|> WebHelpers.close_or_navigate(Paths.projects())}
project ->
if connected?(socket) do
# Per-project topic covers assignment/dependency events for this
# project; the tasks topic covers library-level task renames so
# the visible assignment rows don't go stale.
ProjectsPubSub.subscribe(ProjectsPubSub.topic_project(project.uuid))
ProjectsPubSub.subscribe(ProjectsPubSub.topic_tasks())
end
is_template = project.is_template
lang = L10n.current_content_lang()
wrapper_class = Map.get(session, "wrapper_class", @default_wrapper_class)
# Resolve the workflow-status list once (read-only — nothing is
# provisioned or seeded here; an unset shared default simply yields
# an empty list). `current_status` is derived from the same list so
# we don't resolve twice.
statuses_available = Statuses.available?()
status_options = if statuses_available, do: Statuses.statuses_for(project), else: []
current_status =
Enum.find(status_options, &(&1.slug == project.current_status_slug))
socket =
socket
|> assign(
page_title: Project.localized_name(project, lang),
statuses_available: statuses_available,
status_options: status_options,
current_status: current_status,
project: project,
is_template: is_template,
wrapper_class: wrapper_class,
editing_duration_uuid: nil,
start_modal_open: false,
start_form: to_form(%{"start_at" => default_start_at_local()}),
# Comments drawer state. `comments_resource` is `nil` when
# closed; a `%{type, uuid, title}` map when open. The
# `CommentsComponent` is keyed on `{type, uuid}` so opening
# different resources doesn't reuse stale state.
comments_resource: nil,
comments_enabled: comments_available?(),
project_comment_count: 0,
assignment_comment_counts: %{},
# Skeleton defaults overwritten by the load_* helpers below;
# they keep the assigns coherent if either helper short-circuits.
assignments: [],
deps_by_assignment: %{},
total_tasks: 0,
done_tasks: 0,
progress_pct: 0,
schedule: nil,
# Sub-project UI state (V127). `expanded_subprojects` holds the
# linking-assignment uuids whose child task list is revealed;
# `subproject_*` maps are keyed by linking-assignment uuid and
# filled lazily (summaries in load_assignments, child tasks on
# first expand).
expanded_subprojects: MapSet.new(),
subproject_summaries: %{},
subproject_child_tasks: %{}
)
|> WebHelpers.attach_open_embed_hook()
{:ok, socket |> load_assignments() |> load_comment_counts()}
end
end
# ── PubSub reactivity ─────────────────────────────────────────
# Catch-all handle_info avoids crashes on unexpected messages.
@impl true
def handle_info({:projects, event, _payload}, socket)
when event in [
:assignment_created,
:assignment_updated,
:assignment_deleted,
:dependency_added,
:dependency_removed,
# Task-library renames change displayed assignment titles.
:task_updated,
:task_deleted
] do
{:noreply, load_assignments(socket)}
end
def handle_info({:projects, event, _payload}, socket)
when event in [
:project_updated,
:project_completed,
:project_reopened,
:project_started,
:project_status_changed
] do
case Projects.get_project(socket.assigns.project.uuid) do
nil ->
{:noreply, socket}
p ->
{:noreply, socket |> assign(project: p) |> refresh_status_state() |> load_assignments()}
end
end
def handle_info({:projects, :project_deleted, _payload}, socket) do
{:noreply,
socket
|> put_flash(:info, gettext("This project was deleted."))
|> WebHelpers.close_or_navigate(Paths.projects())}
end
# `CommentsComponent` notifies its parent LV after every create /
# delete so the button badges can refresh without an extra round
# trip. We reload the full count map regardless of which resource
# changed — both project and assignment counts cost a single
# query each, and the message carries an `action` (`:created |
# :deleted`) that we don't need to discriminate on here.
def handle_info({:comments_updated, _payload}, socket) do
{:noreply, load_comment_counts(socket)}
end
def handle_info(msg, socket) do
Logger.debug("[ProjectShowLive] unexpected handle_info: #{inspect(msg)}")
{:noreply, socket}
end
defp load_assignments(socket) do
project_uuid = socket.assigns.project.uuid
assignments = Projects.list_assignments(project_uuid)
expanded = socket.assigns[:expanded_subprojects] || MapSet.new()
expanded_subs =
Enum.filter(
assignments,
&(Assignment.subproject?(&1) and MapSet.member?(expanded, &1.uuid))
)
# Parent deps PLUS deps for every expanded child project — the inset child
# tasks render through the same `<.task_body>`, which reads
# `deps_by_assignment` keyed by the (globally-unique) assignment uuid. Load
# parent-only and a child task's dependency badges never render.
deps_by_assignment =
[project_uuid | Enum.map(expanded_subs, & &1.child_project_uuid)]
|> Enum.flat_map(&Projects.list_all_dependencies/1)
|> Enum.group_by(& &1.assignment_uuid)
total = length(assignments)
done = Enum.count(assignments, &(&1.status == "done"))
# Rollup is the average of all assignments' `progress_pct` values
# — a half-finished task contributes 50%, not 0%. Auto-completion
# (`recompute_project_completion/1`) stays binary: a project only
# auto-flags as completed when every assignment is `status: "done"`,
# not when the rollup happens to hit 100% by other means.
progress_sum = Enum.reduce(assignments, 0, &(&1.progress_pct + &2))
schedule = calculate_schedule(socket.assigns.project, assignments)
# Sub-project rollup display data (V127): one batched summary query over
# all embedded child projects, keyed by the linking-assignment uuid so the
# row can show the child's task count + progress. Expanded rows also get a
# refreshed child task list so a change in the child reflects immediately.
subproject_summaries = load_subproject_summaries(assignments)
subproject_child_tasks =
Map.new(expanded_subs, fn a -> {a.uuid, Projects.list_assignments(a.child_project_uuid)} end)
# Mirror `Projects.project_summaries/1`: an EMPTY sub-project (its child has
# no assignments of its own yet) is neutral in the progress average — keep
# it in `total` for the task-count display but drop it from the progress
# denominator so it doesn't drag the project's % down before it holds any
# work. Without this the header % here and the dashboard card disagree.
empty_subs =
Enum.count(assignments, fn a ->
Assignment.subproject?(a) and empty_subproject?(Map.get(subproject_summaries, a.uuid))
end)
progress_total = max(total - empty_subs, 0)
assign(socket,
assignments: assignments,
deps_by_assignment: deps_by_assignment,
total_tasks: total,
done_tasks: done,
progress_pct: if(progress_total > 0, do: round(progress_sum / progress_total), else: 0),
schedule: schedule,
subproject_summaries: subproject_summaries,
subproject_child_tasks: subproject_child_tasks
)
end
# `%{linking_assignment_uuid => child_summary}` for every sub-project row.
defp load_subproject_summaries(assignments) do
subs = Enum.filter(assignments, &Assignment.subproject?/1)
children = Enum.map(subs, & &1.child_project) |> Enum.reject(&is_nil/1)
summaries_by_child =
children
|> Projects.project_summaries()
|> Map.new(fn s -> {s.project.uuid, s} end)
Map.new(subs, fn a -> {a.uuid, Map.get(summaries_by_child, a.child_project_uuid)} end)
end
# A sub-project whose child has no assignments of its own — `total == 0` in
# the child's summary (matches `project_summaries/1`'s "empty" definition). A
# missing summary (nil) is treated as empty too: no child rows to summarize.
defp empty_subproject?(%{total: 0}), do: true
defp empty_subproject?(nil), do: true
defp empty_subproject?(_), do: false
# Recomputes the workflow-status list + current selection from the
# (possibly just-reloaded) project. Re-resolves against the live catalog
# pre-start and the cemented local rows post-start, so a `:project_started`
# broadcast naturally flips the source. No-op when entities is unavailable.
defp refresh_status_state(socket) do
if socket.assigns.statuses_available do
project = socket.assigns.project
options = Statuses.statuses_for(project)
current = Enum.find(options, &(&1.slug == project.current_status_slug))
assign(socket, status_options: options, current_status: current)
else
socket
end
end
# Updates the assignment, logs the activity on success, and returns a tuple
# `{:ok, socket}` for the maybe_sync_and_reload pipeline.
# The `complete`/`start_task`/`reopen`/`update_progress` handlers go
# through the server-trusted `update_assignment_status/2` because their
# attrs include `completed_by_uuid` / `completed_at`, which the
# form-safe `update_assignment_form/2` intentionally drops.
defp update_assignment_with_activity(socket, a, attrs, action_name, opts) do
case Projects.update_assignment_status(a, attrs) do
{:ok, _} ->
Activity.log(action_name,
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: Keyword.get(opts, :metadata, %{})
)
recompute_owning_subproject(socket, a)
{:ok, socket}
{:error, cs} ->
Activity.log_failed(action_name,
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: Keyword.get(opts, :metadata, %{})
)
{:error, socket, error_summary(cs, gettext("Could not update task."))}
end
end
defp maybe_sync_and_reload({:ok, socket}) do
{:noreply, socket |> sync_project_completion() |> load_assignments()}
end
defp maybe_sync_and_reload({:error, socket, msg}) do
{:noreply, put_flash(socket, :error, msg)}
end
# Translates Ecto validator messages through the gettext "errors"
# domain — Ecto emits English literals like `"is invalid"` /
# `"must be greater than 0"` from `validate_*` plus interpolation
# bindings; `Gettext.dngettext/6` is the canonical translator for
# those (matches the Phoenix scaffolding pattern). Without this,
# the inline error summary (e.g. on a failed `complete` from a
# validation-rejected status transition) renders English regardless
# of the user's locale — Phase 1 PR #1 review item #15, deferred
# then to Phase 2 C3 + closed in this re-validation batch.
#
# Named `translate_validator_error/1` (not `translate_error/1`) to
# avoid shadowing `PhoenixKitWeb.Components.Core.Input.translate_error/1`
# which is auto-imported by `use PhoenixKitWeb, :live_view`.
defp error_summary(%Ecto.Changeset{errors: errors}, fallback) do
case errors do
[] ->
fallback
errs ->
Enum.map_join(errs, ", ", fn {k, {msg, opts}} ->
"#{humanize_field(k)}: #{translate_validator_error({msg, opts})}"
end)
end
end
# Renders an Ecto field name like `:estimated_duration` as
# `"Estimated duration"` for the cross-field flash summary. The
# per-field input component already humanizes its own label, so this
# only matters for the multi-error fallback.
defp humanize_field(field) do
field
|> to_string()
|> String.replace("_", " ")
|> String.capitalize()
end
defp translate_validator_error({msg, opts}) do
if count = opts[:count] do
Gettext.dngettext(PhoenixKitWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(PhoenixKitWeb.Gettext, "errors", msg, opts)
end
end
# When a mutated assignment belongs to an embedded sub-project (its row is
# rendered inset in the expand panel, V127), recompute that child's project so
# the rollup climbs to the shown project's linking row. A no-op for normal
# tasks (`sync_project_completion/1` already recomputes the shown project).
defp recompute_owning_subproject(socket, %{project_uuid: pid}) do
if pid != socket.assigns.project.uuid do
Projects.recompute_project_completion(pid)
end
:ok
end
defp sync_project_completion(socket) do
case Projects.recompute_project_completion(socket.assigns.project.uuid) do
{:completed, project} ->
Activity.log("projects.project_completed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
socket
|> assign(project: project)
|> put_flash(:info, gettext("🎉 All tasks done — project completed!"))
{:reopened, project} ->
Activity.log("projects.project_reopened",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
assign(socket, project: project)
{:unchanged, _} ->
socket
_ ->
socket
end
end
# Looks up an assignment and verifies it belongs to the project the
# user is currently viewing. Prevents an admin on project A from
# mutating assignments in project B by crafting event params.
# The single task-card row, shared by the main timeline and the inset
# sub-project task lists (V127) — "no reason to make them different".
# `@draggable` gates the sortable wiring + drag handle (off for inset child
# tasks, which reorder on their own page). Child-task events work because
# `scoped_assignment/2` accepts any displayed assignment and
# `update_assignment_with_activity/5` recomputes the assignment's own project.
attr(:a, :map, required: true)
attr(:draggable, :boolean, default: true)
attr(:is_template, :boolean, required: true)
attr(:project, :map, required: true)
attr(:embed_mode, :atom, required: true)
attr(:editing_duration_uuid, :string, default: nil)
attr(:comments_enabled, :boolean, default: false)
attr(:assignment_comment_counts, :map, default: %{})
attr(:deps_by_assignment, :map, default: %{})
defp task_body(assigns) do
~H"""
<div class="card-body py-3 px-4 gap-2">
<%!-- Title row --%>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span
:if={@draggable}
class="pk-drag-handle cursor-grab text-base-content/40 hover:text-base-content shrink-0"
title={gettext("Drag to reorder")}
>
<.icon name="hero-bars-3" class="w-4 h-4" />
</span>
<.assignment_status_badge :if={not @is_template} status={@a.status} />
<span class="font-medium truncate">{TaskSchema.localized_title(@a.task, L10n.current_content_lang())}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<%= if not @is_template do %>
<%= cond do %>
<% @a.status == "todo" -> %>
<button phx-click="start_task" phx-value-uuid={@a.uuid} phx-disable-with={gettext("Starting…")} class="btn btn-warning btn-xs">
{gettext("Start")}
</button>
<% @a.status == "in_progress" -> %>
<button phx-click="complete" phx-value-uuid={@a.uuid} phx-disable-with={gettext("Saving…")} class="btn btn-success btn-xs">
<.icon name="hero-check" class="w-3.5 h-3.5" /> {gettext("Done")}
</button>
<% @a.status == "done" -> %>
<button phx-click="reopen" phx-value-uuid={@a.uuid} phx-disable-with={gettext("Reopening…")} class="btn btn-ghost btn-xs">
{gettext("Reopen")}
</button>
<% end %>
<% end %>
<% a_comment_count = Map.get(@assignment_comment_counts, @a.uuid, 0) %>
<button
:if={@comments_enabled and not @is_template}
type="button"
phx-click="open_comments"
phx-value-type="assignment"
phx-value-uuid={@a.uuid}
phx-value-title={TaskSchema.localized_title(@a.task, L10n.current_content_lang())}
class="btn btn-ghost btn-xs gap-1"
title={gettext("Open comments")}
>
<.icon name="hero-chat-bubble-left" class="w-3.5 h-3.5" />
<span :if={a_comment_count > 0} class="badge badge-xs badge-primary">{a_comment_count}</span>
</button>
<.table_row_menu id={"assignment-menu-#{@a.uuid}"}>
<.smart_menu_link
navigate={Paths.edit_assignment(@a.project_uuid, @a.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "edit", "project_id" => @a.project_uuid, "id" => @a.uuid}}}
embed_mode={@embed_mode}
icon="hero-pencil"
label={gettext("Edit")}
/>
<.table_row_menu_divider />
<.table_row_menu_button
phx-click="remove_assignment"
phx-value-uuid={@a.uuid}
phx-disable-with={gettext("Removing…")}
data-confirm={gettext("Remove \"%{title}\"?", title: TaskSchema.localized_title(@a.task, L10n.current_content_lang()))}
icon="hero-trash"
label={gettext("Remove")}
variant="error"
/>
</.table_row_menu>
</div>
</div>
<%!-- Description --%>
<% lang = L10n.current_content_lang() %>
<% shown_desc = Assignment.localized_description(@a, lang) || TaskSchema.localized_description(@a.task, lang) %>
<div :if={shown_desc} class="text-xs text-base-content/60">{shown_desc}</div>
<%!-- Meta row: duration, assignee, completed by --%>
<div class="flex flex-wrap items-center gap-2 text-xs">
<%= if @editing_duration_uuid == @a.uuid do %>
<% prefill_dur = @a.estimated_duration || @a.task.estimated_duration %>
<% prefill_unit = @a.estimated_duration_unit || @a.task.estimated_duration_unit || "hours" %>
<form phx-submit="save_duration" class="flex items-center gap-1">
<input type="hidden" name="uuid" value={@a.uuid} />
<input type="number" name="estimated_duration" value={prefill_dur} class="input input-xs w-16" min="1" />
<.select name="estimated_duration_unit" value={prefill_unit} options={duration_unit_options()} class="select-xs w-auto" />
<button type="submit" phx-disable-with={gettext("Saving…")} class="btn btn-success btn-xs">
<.icon name="hero-check" class="w-3 h-3" />
</button>
<button type="button" phx-click="cancel_edit_duration" class="btn btn-ghost btn-xs">
<.icon name="hero-x-mark" class="w-3 h-3" />
</button>
</form>
<% else %>
<% dur = format_duration(@a) %>
<button
phx-click="edit_duration"
phx-value-uuid={@a.uuid}
class={[
"badge badge-sm gap-1 cursor-pointer transition-colors",
dur != "—" && "badge-outline hover:bg-primary hover:text-primary-content hover:border-primary",
dur == "—" && "badge-ghost hover:bg-base-300"
]}
>
<.icon name="hero-clock" class="w-3 h-3" />
{if dur != "—", do: dur, else: gettext("Set duration")}
</button>
<% end %>
<% atype = assignee_type(@a) %>
<span :if={atype} class="badge badge-outline badge-sm gap-1">
<.icon name="hero-user" class="w-3 h-3" /> {atype}: {assignee_label(@a)}
</span>
<% weekends? = task_counts_weekends?(@a, @project) %>
<span class={"badge badge-sm gap-1 #{if weekends?, do: "badge-info badge-outline", else: "badge-ghost"}"}>
<%= if weekends? do %>
<.icon name="hero-calendar" class="w-3 h-3" /> {gettext("incl. weekends")}
<% else %>
{gettext("weekdays only")}
<% end %>
</span>
<%= if not @is_template do %>
<%= if @a.track_progress do %>
<.form for={%{}} phx-change="update_progress" class="flex items-center gap-1">
<input type="hidden" name="uuid" value={@a.uuid} />
<input type="range" name="progress_pct" value={@a.progress_pct} min="0" max="100" step="5" phx-debounce="300" class="range range-xs range-primary w-20" />
<span class="text-xs text-base-content/60 w-8">{@a.progress_pct}%</span>
<button type="button" phx-click="toggle_tracking" phx-value-uuid={@a.uuid} phx-disable-with={gettext("Saving…")} title={gettext("Disable percentage tracking")} class="btn btn-ghost btn-xs btn-circle">
<.icon name="hero-x-mark" class="w-3 h-3" />
</button>
</.form>
<% else %>
<button type="button" phx-click="toggle_tracking" phx-value-uuid={@a.uuid} phx-disable-with={gettext("Saving…")} class="badge badge-ghost badge-sm gap-1 cursor-pointer hover:badge-primary" title={gettext("Track progress as a percentage")}>
<.icon name="hero-chart-bar" class="w-3 h-3" /> {gettext("Track %")}
</button>
<% end %>
<% end %>
<span :if={@a.completed_by} class="badge badge-success badge-sm gap-1">
<.icon name="hero-check-circle" class="w-3 h-3" />
{@a.completed_by.email}<%= if @a.completed_at do %>
· {L10n.format_month_day_time(@a.completed_at)}
<% end %>
</span>
</div>
<%!-- Dependencies --%>
<% deps = Map.get(@deps_by_assignment, @a.uuid, []) %>
<div :if={deps != []} class="flex flex-wrap gap-1 mt-1">
<%= for dep <- deps do %>
<span class="badge badge-outline badge-xs gap-1">
<.icon name="hero-arrow-right-circle" class="w-3 h-3" />
{gettext("depends on:")} {Assignment.label(dep.depends_on, L10n.current_content_lang())}
<button phx-click="remove_dependency" phx-value-assignment={@a.uuid} phx-value-depends_on={dep.depends_on_uuid} phx-disable-with={gettext("Removing…")} class="hover:text-error">
<.icon name="hero-x-mark" class="w-3 h-3" />
</button>
</span>
<% end %>
</div>
</div>
"""
end
# Accepts any assignment currently displayed on the page: one belonging to the
# shown project, or a task inside an expanded sub-project (V127). Child-task
# rows render the same `task_card` and their events flow through here.
defp scoped_assignment(socket, uuid) do
case Projects.get_assignment(uuid) do
%{project_uuid: pid} = a when pid == socket.assigns.project.uuid ->
a
%{uuid: id} = a ->
if displayed_child_task?(socket, id), do: a, else: nil
_ ->
nil
end
end
defp displayed_child_task?(socket, uuid) do
socket.assigns
|> Map.get(:subproject_child_tasks, %{})
|> Map.values()
|> Enum.any?(fn tasks -> Enum.any?(tasks, &(&1.uuid == uuid)) end)
end
# ── Events ──────────────────────────────────────────────────────
@impl true
def handle_event("complete", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
attrs = %{
status: "done",
progress_pct: 100,
completed_by_uuid: Activity.actor_uuid(socket),
completed_at: DateTime.utc_now()
}
socket
|> update_assignment_with_activity(a, attrs, "projects.assignment_completed",
metadata: %{"task" => Assignment.label(a), "project" => socket.assigns.project.name}
)
|> maybe_sync_and_reload()
end
end
def handle_event("start_task", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
new_pct = if a.progress_pct == 100, do: 0, else: a.progress_pct
attrs = %{status: "in_progress", progress_pct: new_pct}
socket
|> update_assignment_with_activity(a, attrs, "projects.assignment_started",
metadata: %{"task" => Assignment.label(a)}
)
|> maybe_sync_and_reload()
end
end
def handle_event("reopen", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
attrs = %{
status: "todo",
progress_pct: 0,
completed_by_uuid: nil,
completed_at: nil
}
socket
|> update_assignment_with_activity(a, attrs, "projects.assignment_reopened",
metadata: %{"task" => Assignment.label(a)}
)
|> maybe_sync_and_reload()
end
end
def handle_event("edit_duration", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
_a ->
# Just flip the assign — the template sources the prefilled
# values directly from the assignment row via the `:for=` loop
# variable, so there's nothing to stage into a form here.
{:noreply, assign(socket, editing_duration_uuid: uuid)}
end
end
def handle_event("cancel_edit_duration", _params, socket) do
{:noreply, assign(socket, editing_duration_uuid: nil)}
end
def handle_event(
"save_duration",
%{"estimated_duration" => dur, "estimated_duration_unit" => unit},
socket
) do
uuid = socket.assigns.editing_duration_uuid
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
old_dur = "#{a.estimated_duration} #{a.estimated_duration_unit}"
attrs = %{estimated_duration: dur, estimated_duration_unit: unit}
case Projects.update_assignment_form(a, attrs) do
{:ok, _} ->
Activity.log("projects.assignment_duration_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{
"task" => Assignment.label(a),
"from" => old_dur,
"to" => "#{dur} #{unit}"
}
)
recompute_owning_subproject(socket, a)
{:noreply,
socket
|> assign(editing_duration_uuid: nil)
|> load_assignments()}
{:error, cs} ->
Activity.log_failed("projects.assignment_duration_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{
"task" => Assignment.label(a),
"from" => old_dur,
"to" => "#{dur} #{unit}"
}
)
{:noreply,
socket
|> assign(editing_duration_uuid: nil)
|> put_flash(:error, error_summary(cs, gettext("Could not update duration.")))}
end
end
end
def handle_event("remove_assignment", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
case Projects.delete_assignment(a) do
{:ok, _} ->
Activity.log("projects.assignment_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a)}
)
recompute_owning_subproject(socket, a)
{:noreply,
socket
|> WebHelpers.notify_deleted(:assignment, uuid)
|> put_flash(:info, gettext("Task removed."))
|> sync_project_completion()
|> load_assignments()}
{:error, _} ->
Activity.log_failed("projects.assignment_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a)}
)
{:noreply, put_flash(socket, :error, gettext("Could not remove task."))}
end
end
end
# ── Sub-projects (V127) ──────────────────────────────────────────
def handle_event("toggle_subproject", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
%Assignment{child_project_uuid: child_uuid} when is_binary(child_uuid) ->
expanded = socket.assigns.expanded_subprojects
if MapSet.member?(expanded, uuid) do
{:noreply, assign(socket, expanded_subprojects: MapSet.delete(expanded, uuid))}
else
child_tasks = Projects.list_assignments(child_uuid)
# Merge the child's dependencies so the inset child tasks' badges
# render (they read `@deps_by_assignment`, keyed by assignment uuid).
child_deps =
child_uuid |> Projects.list_all_dependencies() |> Enum.group_by(& &1.assignment_uuid)
{:noreply,
assign(socket,
expanded_subprojects: MapSet.put(expanded, uuid),
subproject_child_tasks:
Map.put(socket.assigns.subproject_child_tasks, uuid, child_tasks),
deps_by_assignment: Map.merge(socket.assigns.deps_by_assignment, child_deps)
)}
end
_ ->
{:noreply, socket}
end
end
def handle_event("detach_subproject", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
%Assignment{child_project_uuid: child_uuid} = a when is_binary(child_uuid) ->
case Projects.detach_subproject(a) do
{:ok, _} ->
Activity.log("projects.subproject_detached",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: child_uuid,
metadata: %{"parent_project_uuid" => socket.assigns.project.uuid}
)
{:noreply,
socket
|> put_flash(:info, gettext("Sub-project is now a standalone project."))
|> sync_project_completion()
|> load_assignments()}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Could not detach the sub-project."))}
end
_ ->
{:noreply, socket}
end
end
def handle_event("update_progress", %{"uuid" => uuid, "progress_pct" => pct_str}, socket) do
case scoped_assignment(socket, uuid) do
nil -> {:noreply, socket}
a -> do_update_progress(socket, a, parse_pct(pct_str))
end
end
def handle_event("toggle_tracking", %{"uuid" => uuid}, socket) do
case scoped_assignment(socket, uuid) do
nil ->
{:noreply, socket}
a ->
new_value = not a.track_progress
case Projects.update_assignment_form(a, %{track_progress: new_value}) do
{:ok, _} ->
Activity.log("projects.assignment_tracking_toggled",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a), "track_progress" => new_value}
)
recompute_owning_subproject(socket, a)
{:noreply, load_assignments(socket)}
{:error, _} ->
Activity.log_failed("projects.assignment_tracking_toggled",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: uuid,
metadata: %{"task" => Assignment.label(a), "track_progress" => new_value}
)
{:noreply, put_flash(socket, :error, gettext("Could not toggle tracking."))}
end
end
end
def handle_event("remove_dependency", %{"assignment" => a_uuid, "depends_on" => d_uuid}, socket) do
# Both assignments must belong to the currently-viewed project —
# prevents an admin on project A from unlinking deps in project B.
# Cross-project mismatches are silent noops (UI never offers them).
# An actual `remove_dependency/2` failure is rare but logged via
# `log_failed` so a Postgres outage doesn't erase the click.
# Both endpoints are scope-checked in a single query (distinct uuids →
# exactly two rows when both live in this project); a missing or
# cross-project endpoint yields <2 rows and is a silent no-op.
case Projects.scoped_assignments([a_uuid, d_uuid], socket.assigns.project.uuid) do
[_, _] ->
case Projects.remove_dependency(a_uuid, d_uuid) do
{:ok, _} ->
Activity.log("projects.dependency_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a_uuid,
target_uuid: d_uuid,
metadata: %{}
)
{:noreply, load_assignments(socket)}
{:error, _} ->
Activity.log_failed("projects.dependency_removed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a_uuid,
target_uuid: d_uuid,
metadata: %{}
)
{:noreply, put_flash(socket, :error, gettext("Could not remove dependency."))}
end
_ ->
{:noreply, socket}
end
end
# Opens the start-project modal pre-filled with today's date. The
# actual DB write happens in `confirm_start_project` so users can
# backdate (project was already running before the system was set up)
# or future-date (preparing the project but actual start is later).
# Falls through to a no-op for projects already started — defensive
# against double-clicks racing the LV's render of the now-hidden
# button.
def handle_event("open_start_modal", _params, socket) do
if socket.assigns.project.started_at do
{:noreply, socket}
else
{:noreply,
assign(socket,
start_modal_open: true,
start_form: to_form(%{"start_at" => default_start_at_local()})
)}
end
end
def handle_event("close_start_modal", _params, socket) do
{:noreply, assign(socket, start_modal_open: false)}
end
def handle_event("confirm_start_project", %{"start_at" => datetime_str}, socket) do
case parse_start_at(datetime_str) do
{:ok, started_at} ->
do_start_project(socket, started_at)
{:error, msg} ->
{:noreply, put_flash(socket, :error, msg)}
end
end
def handle_event("change_workflow_status", %{"status_slug" => slug}, socket) do
slug = if slug in [nil, ""], do: nil, else: slug
project = socket.assigns.project
previous = project.current_status_slug
case Statuses.set_current_status(project, slug, actor_uuid: Activity.actor_uuid(socket)) do
{:ok, updated} ->
Activity.log("projects.project_status_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: updated.uuid,
metadata: %{
"name" => updated.name,
"status_slug" => slug,
"previous_status_slug" => previous
}
)
{:noreply, socket |> assign(project: updated) |> refresh_status_state()}
{:error, _reason} ->
Activity.log_failed("projects.project_status_changed",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"status_slug" => slug}
)
{:noreply, put_flash(socket, :error, gettext("Could not change the status."))}
end
end
def handle_event("archive_project", _params, socket) do
case Projects.archive_project(socket.assigns.project) do
{:ok, project} ->
Activity.log("projects.project_archived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
{:noreply,
assign(socket, project: project) |> put_flash(:info, gettext("Project archived."))}
{:error, _} ->
Activity.log_failed("projects.project_archived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: socket.assigns.project.uuid,
metadata: %{"name" => socket.assigns.project.name}
)
{:noreply, put_flash(socket, :error, gettext("Could not archive project."))}
end
end
# Comments drawer. Opening sets `comments_resource` to the
# `(type, uuid, title)` triple of the target so the drawer header
# can show context and the embedded `CommentsComponent` is keyed
# uniquely per resource. Closing clears the assign — the
# component unmounts and any in-flight reply state is dropped
# (intended: drawer-close is a "step away" affordance).
def handle_event("open_comments", %{"type" => type, "uuid" => uuid} = params, socket)
when type in ["project", "assignment"] do
title = Map.get(params, "title", "")
{:noreply, assign(socket, comments_resource: %{type: type, uuid: uuid, title: title})}
end
def handle_event("close_comments", _params, socket) do
{:noreply, assign(socket, comments_resource: nil)}
end
# SortableGrid drop handler. Validates the new order against the
# project's assignments, applies positions atomically, and pushes a
# `sortable:flash` back so the dragged card flashes green/red. The
# LV reload happens via the assignment_updated PubSub fan-out
# triggered by the position writes — no explicit `load_assignments`
# needed here.
def handle_event("reorder_assignments", %{"ordered_ids" => ordered_ids} = params, socket)
when is_list(ordered_ids) do
moved_id = params["moved_id"]
project_uuid = socket.assigns.project.uuid
case Projects.reorder_assignments(project_uuid, ordered_ids,
actor_uuid: Activity.actor_uuid(socket)
) do
:ok ->
{:noreply,
socket
|> push_event("sortable:flash", %{uuid: moved_id, status: "ok"})
|> load_assignments()}
{:error, :too_many_uuids} ->
{:noreply,
socket
|> put_flash(:error, gettext("Too many tasks to reorder at once."))
|> push_event("sortable:flash", %{uuid: moved_id, status: "error"})
|> load_assignments()}
{:error, :not_in_project} ->
# Stale view — a concurrent change moved an assignment out of
# this project. Snap back to the persisted state.
{:noreply,
socket
|> put_flash(:error, gettext("Tasks have changed; please try again."))
|> push_event("sortable:flash", %{uuid: moved_id, status: "error"})
|> load_assignments()}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(:error, gettext("Could not reorder tasks."))
|> push_event("sortable:flash", %{uuid: moved_id, status: "error"})
|> load_assignments()}
end
end
def handle_event("unarchive_project", _params, socket) do
case Projects.unarchive_project(socket.assigns.project) do
{:ok, project} ->
Activity.log("projects.project_unarchived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{"name" => project.name}
)
{:noreply,
assign(socket, project: project) |> put_flash(:info, gettext("Project unarchived."))}
{:error, _} ->
Activity.log_failed("projects.project_unarchived",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: socket.assigns.project.uuid,
metadata: %{"name" => socket.assigns.project.name}
)
{:noreply, put_flash(socket, :error, gettext("Could not unarchive project."))}
end
end
# ── Helpers ─────────────────────────────────────────────────────
# `<input type="datetime-local">` posts `YYYY-MM-DDTHH:mm` (no
# seconds, no timezone). Treat as UTC — what the user typed is what
# gets stored. Pad seconds when missing so `NaiveDateTime` accepts it.
defp parse_start_at(value) when is_binary(value) do
with_seconds = if String.length(value) == 16, do: value <> ":00", else: value
case NaiveDateTime.from_iso8601(with_seconds) do
{:ok, ndt} ->
{:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
{:error, _} ->
{:error, gettext("Invalid date — please pick a valid date and time.")}
end
end
defp parse_start_at(_),
do: {:error, gettext("Invalid date — please pick a valid date and time.")}
# True only when the `phoenix_kit_comments` module is loaded AND
# admin-enabled. Off-by-default `enabled?/0` rescues any error
# (missing tables, sandbox-down) and returns false, so this stays
# safe in early-install or test environments.
defp comments_available? do
Code.ensure_loaded?(PhoenixKitComments) and PhoenixKitComments.enabled?()
end
# Refreshes both the project-level and per-assignment comment
# counts. Called from mount + after `:comments_updated` so the
# button badges stay in sync with reality. Cheap: project count is
# one query, assignment counts are one batched query keyed by
# uuid — no N+1 even with long timelines.
defp load_comment_counts(socket) do
if socket.assigns[:comments_enabled] do
project_uuid = socket.assigns.project.uuid
# Rescue narrowed to the shapes we actually expect: comments are
# optional (UndefinedFunctionError when the module is absent
# mid-install / mid-Hex-bump) and DB transients shouldn't break
# the badge. Anything else surfaces and gets fixed.
project_count =
try do
PhoenixKitComments.count_comments("project", project_uuid, status: "published")
rescue
UndefinedFunctionError -> 0
Postgrex.Error -> 0
DBConnection.OwnershipError -> 0
catch
:exit, _reason -> 0
end
assignment_uuids = Enum.map(socket.assigns.assignments, & &1.uuid)
assignment_counts = Projects.comment_counts_for_assignments(assignment_uuids)
assign(socket,
project_comment_count: project_count,
assignment_comment_counts: assignment_counts
)
else
socket
end
end
# Default value for `<input type="datetime-local">`: today at the
# current hour:minute, formatted `YYYY-MM-DDTHH:mm` (the format the
# browser expects). Built from UTC so the prefilled value matches
# what'll be persisted when the user clicks Start without changing
# anything.
defp default_start_at_local do
DateTime.utc_now()
|> DateTime.truncate(:second)
|> DateTime.to_naive()
|> NaiveDateTime.to_iso8601()
|> String.slice(0, 16)
end
defp do_start_project(socket, started_at) do
case Projects.start_project(socket.assigns.project, started_at) do
{:ok, project} ->
Activity.log("projects.project_started",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: project.uuid,
metadata: %{
"name" => project.name,
"started_at" => DateTime.to_iso8601(started_at)
}
)
{:noreply,
socket
|> assign(project: project, start_modal_open: false)
|> put_flash(:info, gettext("Project started!"))}
{:error, _} ->
Activity.log_failed("projects.project_started",
actor_uuid: Activity.actor_uuid(socket),
resource_type: "project",
resource_uuid: socket.assigns.project.uuid,
metadata: %{
"name" => socket.assigns.project.name,
"started_at" => DateTime.to_iso8601(started_at)
}
)
{:noreply, put_flash(socket, :error, gettext("Could not start project."))}
end
end
defp parse_pct(pct_str) do
case Integer.parse(pct_str) do
{n, _} -> max(0, min(n, 100))
:error -> 0
end
end
defp progress_attrs(100, socket),
do: %{
progress_pct: 100,
status: "done",
completed_by_uuid: Activity.actor_uuid(socket),
completed_at: DateTime.utc_now()
}
defp progress_attrs(0, _socket),
do: %{progress_pct: 0, status: "todo", completed_by_uuid: nil, completed_at: nil}
defp progress_attrs(pct, _socket),
do: %{progress_pct: pct, status: "in_progress", completed_by_uuid: nil, completed_at: nil}
defp progress_action(100, prev_status) when prev_status != "done",
do: "projects.assignment_completed"
defp progress_action(pct, "todo") when pct > 0, do: "projects.assignment_started"
defp progress_action(0, prev_status) when prev_status != "todo",
do: "projects.assignment_reopened"
defp progress_action(_pct, _prev_status), do: "projects.assignment_progress_updated"
defp do_update_progress(socket, a, pct) do
attrs = progress_attrs(pct, socket)
# Uses the server-trusted path because `progress_attrs/2` sets
# `completed_by_uuid` / `completed_at` on the 100% and 0% branches.
case Projects.update_assignment_status(a, attrs) do
{:ok, _} ->
Activity.log(progress_action(pct, a.status),
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: %{"task" => Assignment.label(a), "progress_pct" => pct}
)
recompute_owning_subproject(socket, a)
socket =
if attrs.status != a.status, do: sync_project_completion(socket), else: socket
{:noreply, load_assignments(socket)}
{:error, cs} ->
Activity.log_failed(progress_action(pct, a.status),
actor_uuid: Activity.actor_uuid(socket),
resource_type: "assignment",
resource_uuid: a.uuid,
metadata: %{"task" => Assignment.label(a), "progress_pct" => pct}
)
{:noreply,
put_flash(socket, :error, error_summary(cs, gettext("Could not update progress.")))}
end
end
defp assignee_label(a) do
cond do
a.assigned_person && a.assigned_person.user -> a.assigned_person.user.email
a.assigned_team -> a.assigned_team.name
a.assigned_department -> a.assigned_department.name
true -> nil
end
end
defp assignee_type(a) do
cond do
a.assigned_person_uuid -> gettext("Person")
a.assigned_team_uuid -> gettext("Team")
a.assigned_department_uuid -> gettext("Dept")
true -> nil
end
end
# ── Schedule calculation ─────────────────────────────────────────
defp to_hours(n, unit, counts_weekends), do: TaskSchema.to_hours(n, unit, counts_weekends)
defp task_counts_weekends?(a, project) do
case a.counts_weekends do
nil -> project.counts_weekends
val -> val
end
end
defp assignment_hours(a, project) do
weekends? = task_counts_weekends?(a, project)
if a.estimated_duration && a.estimated_duration_unit do
to_hours(a.estimated_duration, a.estimated_duration_unit, weekends?)
else
to_hours(a.task.estimated_duration, a.task.estimated_duration_unit, weekends?)
end
end
defp calculate_schedule(%{started_at: nil}, _), do: nil
defp calculate_schedule(_project, []), do: nil
defp calculate_schedule(project, assignments) do
{total_hours, effective_done} = sum_hours(project, assignments)
if total_hours == 0 do
nil
else
build_schedule(project, total_hours, effective_done)
end
end
defp sum_hours(project, assignments) do
{total, done, progress} =
Enum.reduce(assignments, {0, 0, 0}, fn a, {total, done, progress} ->
accumulate_hours(a, project, total, done, progress)
end)
{total, done + progress}
end
defp accumulate_hours(%{status: "done"} = a, project, total, done, progress) do
hours = assignment_hours(a, project)
{total + hours, done + hours, progress}
end
defp accumulate_hours(
%{track_progress: true, progress_pct: pct} = a,
project,
total,
done,
progress
)
when pct > 0 do
hours = assignment_hours(a, project)
{total + hours, done, progress + hours * pct / 100}
end
defp accumulate_hours(a, project, total, done, progress) do
{total + assignment_hours(a, project), done, progress}
end
defp build_schedule(project, total_hours, effective_done) do
now = DateTime.utc_now()
calendar_hours = DateTime.diff(now, project.started_at, :second) / 3600
# `planned_end_for/2` honors `counts_weekends`: for weekday-only
# projects weekend days don't consume the work budget. We keep it
# internal (drives the overdue/expected math) — it isn't displayed
# to the user; the started-at date + the per-row durations + the
# ETA below already tell the same story.
planned_end = Project.planned_end_for(project, total_hours)
remaining_hours = max(total_hours - effective_done, 0)
past_planned_end? = DateTime.compare(now, planned_end) == :gt
done? = remaining_hours <= 0
# Planned-elapsed = work hours under the project's schedule rules.
planned_elapsed_hours =
if project.counts_weekends,
do: calendar_hours,
else: work_hours_elapsed(project.started_at, now)
# Cap expected at 100% once calendar time has blown past the
# planned end. Otherwise a project with all weekends elapsed and a
# short total can report "0% expected" — which then makes a 0%-done
# project look "on time" instead of overdue.
raw_expected_pct = planned_elapsed_hours / total_hours * 100
expected_pct =
if past_planned_end? and not done? do
100.0
else
min(raw_expected_pct, 100)
end
# Cap defensively: nothing rendered should exceed 100% even if
# `effective_done` somehow drifts past `total_hours` (e.g. task
# durations edited downward after work was logged).
actual_pct = min(effective_done / total_hours * 100, 100)
delta_pct = actual_pct - expected_pct
overdue? = past_planned_end? and not done?
# When overdue, report calendar lateness ("1 day overdue") rather
# than work-hours-equivalent — for a 52-minute task that's 2 days
# late, "< 1 hour behind" reads as nearly-on-time, which is wrong.
delta_label =
if overdue? do
delta_days(now, planned_end)
else
{v, u} = humanize_hours(abs(delta_pct / 100 * total_hours))
"#{v} #{u}"
end
%{
total_hours: total_hours,
done_hours: effective_done,
remaining_hours: remaining_hours,
elapsed_hours: planned_elapsed_hours,
expected_pct: round(expected_pct),
actual_pct: round(actual_pct),
delta_pct: round(delta_pct),
ahead?: delta_pct >= 0 and not overdue?,
overdue?: overdue?,
delta_label: delta_label,
projected_end: projected_end(project, now, remaining_hours)
}
end
# ETA = "if work continues at the original pace from now, this is
# when you'll finish". For completed projects, return the actual
# completion time. For unfinished projects, anchor on `now` and walk
# `remaining_hours` forward through the project's weekday/weekend
# rules — same machinery `planned_end_for/2` uses, just with a
# different start anchor (see `Project.eta_from/3`).
defp projected_end(%{completed_at: %DateTime{} = at}, _now, _remaining), do: at
defp projected_end(_project, now, remaining) when remaining <= 0, do: now
defp projected_end(project, now, remaining), do: Project.eta_from(project, now, remaining)
# Mirrors `Project.planned_end_for/2`'s weekday-only model: each
# weekday's calendar time contributes work hours at the 3:1 ratio
# (24 calendar hours = 8 work hours); weekend days contribute zero.
# Walks the calendar day-by-day clipping the start/end days.
defp work_hours_elapsed(%DateTime{} = from, %DateTime{} = to) do
if DateTime.compare(from, to) != :lt do
0
else
sum_weekday_calendar_hours(from, to) / 3.0
end
end
defp sum_weekday_calendar_hours(from, to) do
from_date = DateTime.to_date(from)
to_date = DateTime.to_date(to)
Date.range(from_date, to_date)
|> Enum.reduce(0.0, fn date, acc ->
if Date.day_of_week(date) <= 5 do
acc + weekday_calendar_hours_on(date, from, to)
else
acc
end
end)
end
defp weekday_calendar_hours_on(date, from, to) do
sod = DateTime.new!(date, ~T[00:00:00.000], from.time_zone)
eod = DateTime.new!(date, ~T[23:59:59.999], from.time_zone)
window_start = if DateTime.compare(from, sod) == :gt, do: from, else: sod
window_end = if DateTime.compare(to, eod) == :lt, do: to, else: eod
if DateTime.compare(window_start, window_end) == :lt do
DateTime.diff(window_end, window_start, :second) / 3600
else
0
end
end
defp delta_days(later, earlier) do
seconds = DateTime.diff(later, earlier, :second)
hours = seconds / 3600
days = hours / 24
cond do
hours < 1 -> gettext("< 1 hour")
days < 1 -> ngettext("%{count} hour", "%{count} hours", round(hours))
days < 2 -> gettext("1 day")
days < 14 -> ngettext("%{count} day", "%{count} days", round(days))
days < 60 -> gettext("%{n} weeks", n: Float.round(days / 7, 1))
true -> gettext("%{n} months", n: Float.round(days / 30, 1))
end
end
# Calendar-scale boundaries (1h, 24h, 7d, 30d) so unit transitions
# land at intuitive points. Previously transitioned at 8h/40h which
# came from "1 workday = 8h" and produced jarring jumps like
# 7.9h → "8 hours", 8h → "1.0 days".
defp humanize_hours(h) when h < 1, do: {gettext("< 1"), gettext("hour")}
defp humanize_hours(h) when h < 24, do: {round(h), gettext("hours")}
defp humanize_hours(h) when h < 24 * 7, do: {Float.round(h / 24, 1), gettext("days")}
defp humanize_hours(h) when h < 24 * 30, do: {Float.round(h / (24 * 7), 1), gettext("weeks")}
defp humanize_hours(h), do: {Float.round(h / (24 * 30), 1), gettext("months")}
defp format_duration(a) do
dur = a.estimated_duration
unit = a.estimated_duration_unit
if dur && unit do
TaskSchema.format_duration(dur, unit)
else
TaskSchema.format_duration(a.task.estimated_duration, a.task.estimated_duration_unit)
end
end
defp duration_unit_options do
[
{gettext("Minutes"), "minutes"},
{gettext("Hours"), "hours"},
{gettext("Days"), "days"},
{gettext("Weeks"), "weeks"},
{gettext("Fortnights"), "fortnights"},
{gettext("Months"), "months"},
{gettext("Years"), "years"}
]
end
# ── Render ──────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div class={@wrapper_class}>
<%!-- Header --%>
<div>
<.smart_link
navigate={if @is_template, do: Paths.templates(), else: Paths.projects()}
emit={
if @is_template,
do: {PhoenixKitProjects.Web.TemplatesLive, %{}},
else: {PhoenixKitProjects.Web.ProjectsLive, %{}}
}
embed_mode={@embed_mode}
class="link link-hover text-sm"
>
<.icon name="hero-arrow-left" class="w-4 h-4 inline" />
{if @is_template, do: gettext("Templates"), else: gettext("Projects")}
</.smart_link>
<div class="flex flex-col gap-2 mt-1">
<%!-- Title + status badges. Min-width: 0 lets the h1 truncate
instead of pushing siblings around. Long project names
wrap rather than overflowing the column. --%>
<div class="flex flex-wrap items-center gap-2 min-w-0">
<h1 class="text-2xl font-bold break-words">{Project.localized_name(@project, L10n.current_content_lang())}</h1>
<%= if @is_template do %>
<span class="badge badge-info badge-sm">{gettext("Template")}</span>
<% end %>
<%= if @project.completed_at do %>
<span class="badge badge-success gap-1">
<.icon name="hero-check-circle" class="w-3.5 h-3.5" /> {gettext("Completed")}
</span>
<% end %>
<%= if @project.archived_at do %>
<span class="badge badge-ghost gap-1">
<.icon name="hero-archive-box" class="w-3.5 h-3.5" /> {gettext("Archived")}
</span>
<% end %>
<%!-- User-defined workflow status (entities-backed), alongside
the computed lifecycle badges above. Renders nothing when
unset or when the entities module is unavailable. --%>
<.workflow_status_badge :if={@statuses_available} status={@current_status} />
</div>
<%!-- Description sits directly under the title, above the buttons —
title + subtitle as a stacked pair before the action row. --%>
<% desc = Project.localized_description(@project, L10n.current_content_lang()) %>
<p :if={desc} class="text-sm text-base-content/60">
{desc}
</p>
<%!-- Assignee (V128) — who the project is assigned to. Reuses the same
assignee helpers the task rows use; a Project carries the same
polymorphic assignee fields. --%>
<div :if={assignee_type(@project)} class="mt-0.5">
<span class="badge badge-outline badge-sm gap-1">
<.icon name="hero-user" class="w-3 h-3" />
{assignee_type(@project)}: {assignee_label(@project)}
</span>
</div>
<%!-- Action buttons. Separate row so a long title never crowds
them out; `flex-wrap` keeps the row tidy on narrow viewports. --%>
<div class="flex flex-wrap gap-2">
<.smart_link
navigate={Paths.new_assignment(@project.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => @project.uuid}}}
embed_mode={@embed_mode}
class="btn btn-primary btn-sm"
>
<.icon name="hero-plus" class="w-4 h-4" /> {gettext("Add task")}
</.smart_link>
<%!-- Add a sub-project via the same add page tasks use
(`AssignmentFormLive` in sub-project mode, V127) — name +
assignee + dependencies. Template sub-projects deep-clone on
instantiation. --%>
<.smart_link
navigate={Paths.new_assignment(@project.uuid) <> "?kind=subproject"}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => @project.uuid, "kind" => "subproject"}}}
embed_mode={@embed_mode}
class="btn btn-outline btn-sm gap-1"
>
<.icon name="hero-folder-plus" class="w-4 h-4" /> {gettext("Add sub-project")}
</.smart_link>
<%!-- Inline workflow-status picker (the current value). The
status-list *source* is chosen on the new/edit form (and the
global default in Settings), not here. Hidden when no
statuses exist for the project's list. --%>
<form
:if={@statuses_available and @status_options != []}
phx-change="change_workflow_status"
class="flex items-center"
>
<.select
name="status_slug"
value={@project.current_status_slug}
options={Enum.map(@status_options, &{&1.label, &1.slug})}
prompt={gettext("No status")}
class="select-sm"
/>
</form>
<button
:if={@comments_enabled}
type="button"
phx-click="open_comments"
phx-value-type="project"
phx-value-uuid={@project.uuid}
phx-value-title={Project.localized_name(@project, L10n.current_content_lang())}
class="btn btn-ghost btn-sm gap-1"
title={gettext("Open project comments")}
>
<.icon name="hero-chat-bubble-left-right" class="w-4 h-4" /> {gettext("Comments")}
<span :if={@project_comment_count > 0} class="badge badge-sm badge-primary">
{@project_comment_count}
</span>
</button>
<%!-- Edit + (Un)archive go into a kebab dropdown to match the
per-row action pattern used elsewhere in the module. --%>
<.table_row_menu id={"project-header-menu-#{@project.uuid}"}>
<.smart_menu_link
navigate={if @is_template, do: Paths.edit_template(@project.uuid), else: Paths.edit_project(@project.uuid)}
emit={
if @is_template,
do:
{PhoenixKitProjects.Web.TemplateFormLive,
%{"live_action" => "edit", "id" => @project.uuid}},
else:
{PhoenixKitProjects.Web.ProjectFormLive,
%{"live_action" => "edit", "id" => @project.uuid}}
}
embed_mode={@embed_mode}
icon="hero-pencil"
label={gettext("Edit")}
/>
<%= if not @is_template do %>
<.table_row_menu_divider />
<%= if @project.archived_at do %>
<.table_row_menu_button
phx-click="unarchive_project"
phx-disable-with={gettext("Unarchiving…")}
icon="hero-arrow-uturn-left"
label={gettext("Unarchive")}
/>
<% else %>
<.table_row_menu_button
phx-click="archive_project"
phx-disable-with={gettext("Archiving…")}
data-confirm={gettext("Archive this project? It will be hidden from the main lists but kept in the database.")}
icon="hero-archive-box"
label={gettext("Archive")}
/>
<% end %>
<% end %>
</.table_row_menu>
</div>
</div>
</div>
<%!-- Start mode / template bar --%>
<div class="flex flex-wrap items-center gap-3 bg-base-200 rounded-lg px-4 py-3">
<%= cond do %>
<% @is_template -> %>
<.icon name="hero-document-duplicate" class="w-5 h-5 text-info" />
<span class="text-sm">{gettext("This is a template — set up tasks, then create projects from it.")}</span>
<.smart_link
navigate={Paths.new_project() <> "?template=#{@project.uuid}"}
emit={{PhoenixKitProjects.Web.ProjectFormLive, %{"live_action" => "new", "template" => @project.uuid}}}
embed_mode={@embed_mode}
class="btn btn-primary btn-xs ml-auto"
>
<.icon name="hero-plus" class="w-4 h-4" /> {gettext("Create project from this template")}
</.smart_link>
<% @project.completed_at -> %>
<.icon name="hero-trophy" class="w-5 h-5 text-success" />
<span class="text-sm font-medium">
{gettext("Completed %{when}", when: L10n.format_datetime(@project.completed_at))}
</span>
<%= if @project.started_at do %>
<span class="text-base-content/40 mx-1">·</span>
<span class="text-sm text-base-content/60">
{gettext("took %{duration}", duration: delta_days(@project.completed_at, @project.started_at))}
</span>
<% end %>
<% @project.started_at -> %>
<.icon name="hero-play" class="w-5 h-5 text-success" />
<span class="text-sm">
{gettext("Started %{when}", when: L10n.format_datetime(@project.started_at))}
</span>
<%= if @schedule do %>
<span class="text-base-content/40 mx-1">·</span>
<%= cond do %>
<% @schedule.overdue? -> %>
<span class="badge badge-error badge-sm gap-1">
<.icon name="hero-exclamation-triangle" class="w-3 h-3" />
{gettext("%{delta} overdue", delta: @schedule.delta_label)}
</span>
<% @schedule.ahead? -> %>
<span class="badge badge-success badge-sm gap-1">
<.icon name="hero-arrow-trending-up" class="w-3 h-3" />
{gettext("%{delta} ahead", delta: @schedule.delta_label)}
</span>
<% true -> %>
<span class="badge badge-error badge-sm gap-1">
<.icon name="hero-arrow-trending-down" class="w-3 h-3" />
{gettext("%{delta} behind", delta: @schedule.delta_label)}
</span>
<% end %>
<span class="text-xs text-base-content/50 ml-1">
{gettext("(%{actual}% done vs %{expected}% expected)", actual: @schedule.actual_pct, expected: @schedule.expected_pct)}
</span>
<% end %>
<% @project.start_mode == "scheduled" -> %>
<.icon name="hero-calendar" class="w-5 h-5 text-info" />
<span class="text-sm">
{gettext("Scheduled for %{when}", when: L10n.format_datetime(@project.scheduled_start_date))}
</span>
<button
type="button"
phx-click="open_start_modal"
class="btn btn-success btn-xs ml-auto"
>
{gettext("Start now")}
</button>
<% true -> %>
<.icon name="hero-clock" class="w-5 h-5 text-warning" />
<span class="text-sm">{gettext("Not started — set up tasks, then start")}</span>
<button
type="button"
phx-click="open_start_modal"
class="btn btn-success btn-xs ml-auto"
>
<.icon name="hero-play" class="w-4 h-4" /> {gettext("Start project")}
</button>
<% end %>
</div>
<%= if @project.started_at != nil and @schedule do %>
<% {rem_v, rem_u} = humanize_hours(@schedule.remaining_hours) %>
<div class="flex flex-wrap items-center gap-3 bg-base-200/50 rounded-lg px-4 py-2 text-xs">
<%= if @project.completed_at do %>
<div class="flex items-center gap-2">
<.icon name="hero-check-circle" class="w-4 h-4 text-success" />
<span class="text-base-content/60">{gettext("Finished:")}</span>
<span class="font-medium">{L10n.format_datetime(@schedule.projected_end)}</span>
</div>
<% else %>
<div class="flex items-center gap-2">
<.icon name="hero-clock" class="w-4 h-4 text-base-content/60" />
<span class="text-base-content/60">{gettext("Remaining:")}</span>
<span class="font-medium">{rem_v} {rem_u}</span>
</div>
<span class="text-base-content/40">·</span>
<div class="flex items-center gap-2">
<.icon name="hero-arrow-trending-up" class={"w-4 h-4 #{if @schedule.ahead?, do: "text-success", else: "text-error"}"} />
<span class="text-base-content/60">{gettext("ETA:")}</span>
<span class={[
"font-medium",
@schedule.ahead? && "text-success",
not @schedule.ahead? && "text-error"
]}>
{L10n.format_datetime(@schedule.projected_end)}
</span>
<span class="text-base-content/40">{gettext("at planned pace")}</span>
</div>
<% end %>
</div>
<% end %>
<%!-- Progress bar (not for templates) --%>
<%= if @total_tasks > 0 and not @is_template do %>
<div class="flex items-center gap-3">
<div class="flex-1">
<div class="w-full bg-base-300 rounded-full h-2">
<div
class="bg-success h-2 rounded-full transition-all duration-300"
style={"width: #{@progress_pct}%"}
>
</div>
</div>
</div>
<span class="text-sm text-base-content/60 shrink-0">
{gettext("%{done}/%{total} done", done: @done_tasks, total: @total_tasks)}
</span>
</div>
<% end %>
<%!-- Timeline --%>
<%= if @assignments == [] do %>
<.empty_state icon="hero-rectangle-stack" title={gettext("No tasks in this project yet.")}>
<:cta>
<.smart_link
navigate={Paths.new_assignment(@project.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => @project.uuid}}}
embed_mode={@embed_mode}
class="link link-primary text-sm"
>
{gettext("Add one from the task library")}
</.smart_link>
</:cta>
</.empty_state>
<% else %>
<div class="relative">
<%!-- Vertical connector line --%>
<div class="absolute left-5 top-0 bottom-0 w-0.5 bg-base-300"></div>
<%!-- SortableGrid hook lives on the inner flex container —
the absolute-positioned vertical line is a sibling
outside it so it doesn't get included in the sortable
item set. The drag handle on each card's title row is
the only initiator (`.pk-drag-handle`), so clicks
anywhere else on the card still trigger the existing
status / duration / dep handlers. --%>
<div
id="project-show-timeline"
class="flex flex-col gap-0"
phx-hook="SortableGrid"
data-sortable="true"
data-sortable-event="reorder_assignments"
data-sortable-items=".sortable-item"
data-sortable-handle=".pk-drag-handle"
>
<%= for {a, idx} <- Enum.with_index(@assignments) do %>
<div class="relative flex gap-4 py-3 sortable-item" data-id={a.uuid}>
<%!-- Status dot on the timeline --%>
<div class="relative z-10 shrink-0 flex flex-col items-center">
<div class={"w-10 h-10 rounded-full flex items-center justify-center text-xs font-bold #{AssignmentStatusBadge.color(a.status)} #{AssignmentStatusBadge.text_color(a.status)} #{AssignmentStatusBadge.ring(a.status)}"}>
<%= if a.status == "done" do %>
<.icon name="hero-check" class="w-5 h-5" />
<% else %>
{idx + 1}
<% end %>
</div>
</div>
<%!-- Card --%>
<div class={"flex-1 card bg-base-100 shadow-sm border #{if not @is_template and a.status == "done", do: "border-success/30 opacity-75", else: "border-base-200"}"}>
<%= if Assignment.subproject?(a) do %>
<div class="card-body py-3 px-4 gap-2">
<% sp_lang = L10n.current_content_lang() %>
<% child = a.child_project %>
<% sp_summary = Map.get(@subproject_summaries, a.uuid) %>
<% sp_expanded? = MapSet.member?(@expanded_subprojects, a.uuid) %>
<%!-- Sub-project title row --%>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="pk-drag-handle cursor-grab text-base-content/40 hover:text-base-content shrink-0" title={gettext("Drag to reorder")}>
<.icon name="hero-bars-3" class="w-4 h-4" />
</span>
<button
type="button"
phx-click="toggle_subproject"
phx-value-uuid={a.uuid}
class="btn btn-ghost btn-xs btn-circle"
title={gettext("Show sub-project tasks")}
>
<.icon name={if sp_expanded?, do: "hero-chevron-down", else: "hero-chevron-right"} class="w-4 h-4" />
</button>
<span class="badge badge-secondary badge-sm gap-1 shrink-0">
<.icon name="hero-folder" class="w-3 h-3" /> {gettext("Sub-project")}
</span>
<.assignment_status_badge :if={not @is_template} status={a.status} />
<span class="font-medium truncate">{Project.localized_name(child, sp_lang)}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<.smart_link
navigate={Paths.project(child.uuid)}
emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => child.uuid}}}
embed_mode={@embed_mode}
class="btn btn-ghost btn-xs gap-1"
>
<.icon name="hero-arrow-top-right-on-square" class="w-3.5 h-3.5" /> {gettext("Open")}
</.smart_link>
<.table_row_menu id={"assignment-menu-#{a.uuid}"}>
<.smart_menu_link
navigate={Paths.project(child.uuid)}
emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => child.uuid}}}
embed_mode={@embed_mode}
icon="hero-arrow-top-right-on-square"
label={gettext("Open sub-project")}
/>
<%!-- Edit name/assignee/dependencies on the same form
tasks use (V127), not a special inline control. --%>
<.smart_menu_link
navigate={Paths.edit_assignment(@project.uuid, a.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "edit", "project_id" => @project.uuid, "id" => a.uuid}}}
embed_mode={@embed_mode}
icon="hero-pencil"
label={gettext("Edit")}
/>
<%!-- Pop the sub-project back out as a standalone
project — keeps it + its tasks (V127). --%>
<.table_row_menu_button
phx-click="detach_subproject"
phx-value-uuid={a.uuid}
phx-disable-with={gettext("Detaching…")}
data-confirm={gettext("Make \"%{name}\" a standalone project? It keeps all its tasks — it just won't be a sub-project anymore.", name: Project.localized_name(child, sp_lang))}
icon="hero-arrow-up-on-square"
label={gettext("Make standalone")}
/>
<.table_row_menu_divider />
<.table_row_menu_button
phx-click="remove_assignment"
phx-value-uuid={a.uuid}
phx-disable-with={gettext("Removing…")}
data-confirm={gettext("Remove sub-project \"%{name}\" and everything inside it?", name: Project.localized_name(child, sp_lang))}
icon="hero-trash"
label={gettext("Remove")}
variant="error"
/>
</.table_row_menu>
</div>
</div>
<%!-- Sub-project description --%>
<% sp_desc = Project.localized_description(child, sp_lang) %>
<div :if={sp_desc} class="text-xs text-base-content/60">{sp_desc}</div>
<%!-- Rolled-up meta (read-only — driven by the child) --%>
<div :if={sp_summary} class="flex flex-wrap items-center gap-2 text-xs">
<span class="badge badge-outline badge-sm gap-1">
<.icon name="hero-rectangle-stack" class="w-3 h-3" />
{gettext("%{done}/%{total} tasks", done: sp_summary.done, total: sp_summary.total)}
</span>
<% {sp_hv, sp_hu} = humanize_hours(sp_summary.total_hours) %>
<span :if={sp_summary.total_hours > 0} class="badge badge-ghost badge-sm gap-1">
<.icon name="hero-clock" class="w-3 h-3" /> {sp_hv} {sp_hu}
</span>
<div class="flex items-center gap-1">
<progress class="progress progress-primary w-20" value={a.progress_pct} max="100"></progress>
<span class="text-base-content/60 w-8">{a.progress_pct}%</span>
</div>
<span :if={assignee_type(child)} class="badge badge-outline badge-sm gap-1">
<.icon name="hero-user" class="w-3 h-3" />
{assignee_type(child)}: {assignee_label(child)}
</span>
</div>
<%!-- This sub-project's own dependencies (on siblings) --%>
<% sp_deps = Map.get(@deps_by_assignment, a.uuid, []) %>
<div :if={sp_deps != []} class="flex flex-wrap gap-1 mt-1">
<%= for dep <- sp_deps do %>
<span class="badge badge-outline badge-xs gap-1">
<.icon name="hero-arrow-right-circle" class="w-3 h-3" />
{gettext("depends on:")} {Assignment.label(dep.depends_on, sp_lang)}
<button
phx-click="remove_dependency"
phx-value-assignment={a.uuid}
phx-value-depends_on={dep.depends_on_uuid}
phx-disable-with={gettext("Removing…")}
class="hover:text-error"
>
<.icon name="hero-x-mark" class="w-3 h-3" />
</button>
</span>
<% end %>
</div>
<%!-- Expanded: the child's own task list + add-dependency --%>
<div :if={sp_expanded?} class="mt-2 rounded-lg bg-base-200/60 p-3 flex flex-col gap-2">
<% child_tasks = Map.get(@subproject_child_tasks, a.uuid, []) %>
<%= if child_tasks == [] do %>
<p class="text-xs text-base-content/50">
{gettext("No tasks in this sub-project yet.")}
<.smart_link
navigate={Paths.new_assignment(child.uuid)}
emit={{PhoenixKitProjects.Web.AssignmentFormLive, %{"live_action" => "new", "project_id" => child.uuid}}}
embed_mode={@embed_mode}
class="link link-primary"
>
{gettext("Add one")}
</.smart_link>
</p>
<% else %>
<div class="flex flex-col gap-2">
<%= for {ct, ci} <- Enum.with_index(child_tasks) do %>
<%= if Assignment.subproject?(ct) do %>
<%!-- A nested sub-project: a compact link (open it to drill in). --%>
<div class="flex items-center gap-2 text-xs">
<.assignment_status_badge status={ct.status} />
<span class="badge badge-secondary badge-xs shrink-0">{gettext("Sub-project")}</span>
<.smart_link
navigate={Paths.project(ct.child_project.uuid)}
emit={{PhoenixKitProjects.Web.ProjectShowLive, %{"id" => ct.child_project.uuid}}}
embed_mode={@embed_mode}
class="truncate flex-1 hover:underline"
>
{Project.localized_name(ct.child_project, sp_lang)}
</.smart_link>
<span class="text-base-content/50">{ct.progress_pct}%</span>
</div>
<% else %>
<%!-- Same task card as the top-level timeline, just inset. --%>
<div class="relative flex gap-3">
<div class="relative z-10 shrink-0 flex flex-col items-center">
<div class={"w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold #{AssignmentStatusBadge.color(ct.status)} #{AssignmentStatusBadge.text_color(ct.status)} #{AssignmentStatusBadge.ring(ct.status)}"}>
<%= if ct.status == "done" do %>
<.icon name="hero-check" class="w-4 h-4" />
<% else %>
{ci + 1}
<% end %>
</div>
</div>
<div class={"flex-1 card bg-base-100 shadow-sm border #{if ct.status == "done", do: "border-success/30 opacity-75", else: "border-base-200"}"}>
<.task_body
a={ct}
draggable={false}
is_template={@is_template}
project={child}
embed_mode={@embed_mode}
editing_duration_uuid={@editing_duration_uuid}
comments_enabled={@comments_enabled}
assignment_comment_counts={@assignment_comment_counts}
deps_by_assignment={@deps_by_assignment}
/>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>
<% else %>
<.task_body
a={a}
draggable={true}
is_template={@is_template}
project={@project}
embed_mode={@embed_mode}
editing_duration_uuid={@editing_duration_uuid}
comments_enabled={@comments_enabled}
assignment_comment_counts={@assignment_comment_counts}
deps_by_assignment={@deps_by_assignment}
/>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Start-project modal — date editable so the user can backdate
an already-running project or queue a future start. The
form's `phx-change="noop"` prevents the LV from rebuilding
the changeset on each keystroke (no live validation needed
for a single date input); submit goes via `phx-submit`. --%>
<%= if @start_modal_open do %>
<dialog open class="modal modal-open" phx-window-keydown="close_start_modal" phx-key="Escape">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg">{gettext("Start project")}</h3>
<p class="text-sm text-base-content/70 mt-1">
{gettext("Pick the date and time this project starts. Defaults to right now; backdate it if work began earlier, or pick a future moment if you're queueing it up.")}
</p>
<.form for={@start_form} phx-submit="confirm_start_project" class="flex flex-col gap-3 mt-4">
<.input field={@start_form[:start_at]} type="datetime-local" label={gettext("Start date and time")} required />
<div class="modal-action">
<button
type="button"
phx-click="close_start_modal"
class="btn btn-ghost btn-sm"
>
{gettext("Cancel")}
</button>
<button
type="submit"
phx-disable-with={gettext("Starting…")}
class="btn btn-success btn-sm"
>
<.icon name="hero-play" class="w-4 h-4" /> {gettext("Start project")}
</button>
</div>
</.form>
</div>
<button type="button" phx-click="close_start_modal" class="modal-backdrop" aria-label={gettext("Close")}></button>
</dialog>
<% end %>
<%!-- Slide-in comments drawer. Right-side fixed panel that
hosts `PhoenixKitComments.Web.CommentsComponent` for either
the project or one of its assignments. The component is
keyed on `{type, uuid}` so opening a different resource
re-mounts with its own state instead of leaking the
previous resource's reply-in-progress / pagination.
Esc + backdrop click both fire `close_comments`. The
component's "comments_updated" message is unhandled here
(we don't need to react to project-level comment counts
in the timeline yet) — the catch-all `handle_info` clause
logs it at debug and moves on. --%>
<%!-- z-[60] / z-[70] so we paint over the admin header
(`fixed top-0 z-50` in the layout wrapper). At z-40 the
backdrop sat behind the header and looked broken. --%>
<%= if @comments_resource do %>
<div
class="fixed inset-0 z-[60] bg-black/40"
phx-click="close_comments"
phx-window-keydown="close_comments"
phx-key="Escape"
aria-hidden="true"
></div>
<aside
class="fixed top-0 right-0 z-[70] h-screen w-full max-w-md bg-base-100 shadow-2xl flex flex-col"
role="dialog"
aria-modal="true"
aria-label={gettext("Comments")}
>
<header class="flex items-start gap-2 p-4 border-b border-base-200 shrink-0">
<div class="flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-base-content/60">
<%= if @comments_resource.type == "project" do %>
{gettext("Project")}
<% else %>
{gettext("Task")}
<% end %>
</div>
<h2 class="font-bold text-lg truncate">{@comments_resource.title}</h2>
</div>
<button
type="button"
phx-click="close_comments"
class="btn btn-ghost btn-sm btn-square"
aria-label={gettext("Close")}
>
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</header>
<div class="flex-1 min-h-0 overflow-y-auto p-4">
<.live_component
module={PhoenixKitComments.Web.CommentsComponent}
id={"comments-drawer-#{@comments_resource.type}-#{@comments_resource.uuid}"}
resource_type={@comments_resource.type}
resource_uuid={@comments_resource.uuid}
current_user={
(scope = assigns[:phoenix_kit_current_scope]) && scope.user
}
title=""
show_likes={true}
/>
</div>
</aside>
<% end %>
</div>
"""
end
end