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. ## List / Timeline tabs The page has two views — the vertical task list and the embedded `ProjectGanttLive` Timeline — toggled by tabs under the shared header. Switching is instant (an assign flip) and the gantt is lazily mounted on first open. **The tabs render in every mount context, embedded `live_render` renders included** (only templates stay list-only). Note this means the Timeline tab is a nested `live_render` of `ProjectGanttLive`, which is itself a nested LV when the show page is embedded — deliberate, server-rendered, so the chart shows even before any JS loads. Keeping the URL in sync (the trailing `/gantt` segment + browser back/forward) is **optional and off by default**: it's only on when `@tab_url_sync?` is true, which the router-mounted standalone admin page sets (so its deep-linking keeps working) and an embed can opt into via `session["tab_url_sync"]`. When on, the `ProjectTabsUrl` JS hook pushes/reads `history` state. With it off the tabs still switch fully — they just never touch the host page's URL (the right default for an embed, which must not rewrite the host's address bar). """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitProjects.Gettext use PhoenixKitProjects.Web.Components # Forwards the comment composer's {:leaf_changed, …} process message into # CommentsComponent.forward_leaf_event/2 via a :handle_info lifecycle hook # (halts only :leaf_changed, passes everything else through); without it # "Post Comment" silently submits empty content. comments is a hard dep here. use PhoenixKitComments.Embed 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 # Schedule/assignee helpers shared with ProjectGanttLive — imported so the # template's bare calls resolve. See PhoenixKitProjects.Web.Helpers. import PhoenixKitProjects.Web.Helpers, only: [assignee_label: 1, task_counts_weekends?: 2, assignment_hours: 2] 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) # Reuse the router mount, then adjust for the embed context. The List/Timeline # tabs DO render in embeds now (the gantt is a nested `live_render`, which is # fine — it's server-rendered). Two things differ from the router mount: # * `router_mounted?: false` — purely informational now (a few comments key # off it); the tab bar no longer gates on it. # * `tab_url_sync?` — OFF by default in an embed (an embed must not rewrite # the host's URL); a host can opt back in with `session["tab_url_sync"]`. # `live_action` is nil here, so `active_tab` already defaults to `:list`. {:ok, socket} = mount(%{"id" => id}, session, socket) {:ok, assign(socket, router_mounted?: false, gantt_mounted?: false, # Strict `== true` so only a real boolean opt-in turns it on (a stray # string would otherwise read as truthy and re-enable URL rewriting). tab_url_sync?: Map.get(session, "tab_url_sync", false) == true )} 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 = socket |> WebHelpers.assign_embed_state(session) |> WebHelpers.assign_embed_user(session) {:ok, socket |> assign( page_title: "", project: %Project{}, is_template: false, wrapper_class: Map.get(session, "wrapper_class", @default_wrapper_class), router_mounted?: false, tab_url_sync?: false, active_tab: :list, gantt_mounted?: false, 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). # Embed user third: when this LV is rendered via `live_render` the # `:phoenix_kit_ensure_admin` on_mount hook never runs, so the host # supplies the current user via `session["current_user_uuid"]`. On the # router path the hook already set the scope and this is a no-op. WebHelpers.maybe_put_locale(session) socket = socket |> WebHelpers.assign_embed_state(session) |> WebHelpers.assign_embed_user(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, router_mounted?: false, # Must be assigned: the tab bar now renders on `not @is_template` # (true here), so the render reads `@tab_url_sync?`. Router-mount # context → true (matches the success branch); the embedded wrapper # overrides it to the session value when this path is reached via an # off-router mount with an unknown id. tab_url_sync?: true, active_tab: :list, gantt_mounted?: false, 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) # Which tab the page opens on, straight from the route's live_action # (`/list/:id/gantt` → `:gantt`, everything else → `:list`). Server-side # so a direct/bookmarked `/gantt` load renders the gantt before any JS. # Templates have no tabs/gantt (both are `not @is_template`), so a template # uuid reached via the `/list/:id/gantt` route falls back to the list — # otherwise both the list and the gantt would render hidden (blank page). active_tab = tab_for_action(socket, is_template) # 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, # Tab state. The tab bar renders in every context now (only # templates stay list-only); `router_mounted?` is kept as an # informational flag a few comments key off. URL sync is ON here — # this is the standalone admin page, which owns a real `/gantt` URL # to deep-link; embeds default it off (see the embed mount clause). # The gantt is lazy-mounted: it only `live_render`s once its tab is # first opened, then stays mounted so its zoom/expand survive. router_mounted?: true, tab_url_sync?: true, active_tab: active_tab, gantt_mounted?: active_tab == :gantt, 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 # Must re-preload the assignee: the render derefs @project.assigned_person.user # (assignee_label/1), so a plain get_project/1 here leaves it NotLoaded and the # re-render crashes when the project has an assignee. case Projects.get_project_with_assignee(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"""
<%!-- Title row --%>
<.icon name="hero-bars-3" class="w-4 h-4" /> <.assignment_status_badge :if={not @is_template} status={@a.status} /> {TaskSchema.localized_title(@a.task, L10n.current_content_lang())}
<%= if not @is_template do %> <%= cond do %> <% @a.status == "todo" -> %> <% @a.status == "in_progress" -> %> <% @a.status == "done" -> %> <% end %> <% end %> <% a_comment_count = Map.get(@assignment_comment_counts, @a.uuid, 0) %> <.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" />
<%!-- Description --%> <% lang = L10n.current_content_lang() %> <% shown_desc = Assignment.localized_description(@a, lang) || TaskSchema.localized_description(@a.task, lang) %>
{shown_desc}
<%!-- Meta row: duration, assignee, completed by --%>
<%= 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" %>
<.select name="estimated_duration_unit" value={prefill_unit} options={duration_unit_options()} class="select-xs w-auto" />
<% else %> <% dur = format_duration(@a) %> <% end %> <% atype = assignee_type(@a) %> <.icon name="hero-user" class="w-3 h-3" /> {atype}: {assignee_label(@a)} <% weekends? = task_counts_weekends?(@a, @project) %> <%= if weekends? do %> <.icon name="hero-calendar" class="w-3 h-3" /> {gettext("incl. weekends")} <% else %> {gettext("weekdays only")} <% end %> <%= if not @is_template do %> <%= if @a.track_progress do %> <.form for={%{}} phx-change="update_progress" class="flex items-center gap-1"> {@a.progress_pct}% <% else %> <% end %> <% end %> <.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 %>
<%!-- Dependencies --%> <% deps = Map.get(@deps_by_assignment, @a.uuid, []) %>
<%= for dep <- deps do %> <.icon name="hero-arrow-right-circle" class="w-3 h-3" /> {gettext("depends on:")} {Assignment.label(dep.depends_on, L10n.current_content_lang())} <% end %>
""" 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 ────────────────────────────────────────────────────── # Switch the List/Timeline tab. Instant (an assign flip, no navigation) and # the gantt's nested LV stays mounted, so its zoom/expand survive. The gantt # is lazy-mounted the first time its tab opens and never unmounted. We push # the URL change to the `ProjectTabsUrl` hook — except when URL sync is off # (the default for embeds), or when the switch itself CAME from the URL # (browser back/forward), to avoid a push/popstate loop. With sync off the # `ProjectTabsUrl` hook isn't even attached, so the event would no-op anyway; # gating it server-side keeps the intent explicit. @impl true def handle_event("switch_tab", %{"tab" => tab} = params, socket) do active = if tab == "gantt", do: :gantt, else: :list socket = socket |> assign(active_tab: active) |> assign(gantt_mounted?: socket.assigns.gantt_mounted? or active == :gantt) socket = if not socket.assigns.tab_url_sync? or params["source"] == "history", do: socket, else: push_event(socket, "project_tab_url", %{tab: tab}) {:noreply, socket} end 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 ───────────────────────────────────────────────────── # `` 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 ``: 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 # The active tab, derived from the route's live_action. `:gantt` is the gantt # tab; `:show`, `:show_template`, and the embedded (nil live_action) mount all # default to the list. Templates never expose the gantt, so they pin to `:list` # even on the `/gantt` route (the tab bar + gantt are `not @is_template`). defp tab_for_action(_socket, true), do: :list defp tab_for_action(socket, _is_template) do case Map.get(socket.assigns, :live_action) do :gantt -> :gantt _ -> :list end 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_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 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"""
<%!-- Header --%>
<.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")}
<%!-- Title + status badges. Min-width: 0 lets the h1 truncate instead of pushing siblings around. Long project names wrap rather than overflowing the column. --%>

{Project.localized_name(@project, L10n.current_content_lang())}

<%= if @is_template do %> {gettext("Template")} <% end %> <%= if @project.completed_at do %> <.icon name="hero-check-circle" class="w-3.5 h-3.5" /> {gettext("Completed")} <% end %> <%= if @project.archived_at do %> <.icon name="hero-archive-box" class="w-3.5 h-3.5" /> {gettext("Archived")} <% 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} />
<%!-- 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()) %>

{desc}

<%!-- 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. --%>
<.icon name="hero-user" class="w-3 h-3" /> {assignee_type(@project)}: {assignee_label(@project)}
<%!-- Action buttons. Separate row so a long title never crowds them out; `flex-wrap` keeps the row tidy on narrow viewports. --%>
<.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")} <%!-- 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")} <%!-- The Gantt/timeline view is now the "Timeline" tab below the header (router-mounted only), so no separate link here. --%> <%!-- 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. --%>
<.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" />
<%!-- 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 %>
<%!-- Start mode / template bar --%>
<%= cond do %> <% @is_template -> %> <.icon name="hero-document-duplicate" class="w-5 h-5 text-info" /> {gettext("This is a template — set up tasks, then create projects from it.")} <.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")} <% @project.completed_at -> %> <.icon name="hero-trophy" class="w-5 h-5 text-success" /> {gettext("Completed %{when}", when: L10n.format_datetime(@project.completed_at))} <%= if @project.started_at do %> · {gettext("took %{duration}", duration: delta_days(@project.completed_at, @project.started_at))} <% end %> <% @project.started_at -> %> <.icon name="hero-play" class="w-5 h-5 text-success" /> {gettext("Started %{when}", when: L10n.format_datetime(@project.started_at))} <%= if @schedule do %> · <%= cond do %> <% @schedule.overdue? -> %> <.icon name="hero-exclamation-triangle" class="w-3 h-3" /> {gettext("%{delta} overdue", delta: @schedule.delta_label)} <% @schedule.ahead? -> %> <.icon name="hero-arrow-trending-up" class="w-3 h-3" /> {gettext("%{delta} ahead", delta: @schedule.delta_label)} <% true -> %> <.icon name="hero-arrow-trending-down" class="w-3 h-3" /> {gettext("%{delta} behind", delta: @schedule.delta_label)} <% end %> {gettext("(%{actual}% done vs %{expected}% expected)", actual: @schedule.actual_pct, expected: @schedule.expected_pct)} <% end %> <% @project.start_mode == "scheduled" -> %> <.icon name="hero-calendar" class="w-5 h-5 text-info" /> {gettext("Scheduled for %{when}", when: L10n.format_datetime(@project.scheduled_start_date))} <% true -> %> <.icon name="hero-clock" class="w-5 h-5 text-warning" /> {gettext("Not started — set up tasks, then start")} <% end %>
<%!-- Schedule summary + progress as ONE card: the progress bar is the card's bottom edge (a thin flush strip), so the two read as a unit. --%> <% show_schedule = @project.started_at != nil and @schedule != nil %> <% show_progress = @total_tasks > 0 and not @is_template %> <%= if show_schedule or show_progress do %>
<%= if show_schedule do %> <% {rem_v, rem_u} = humanize_hours(@schedule.remaining_hours) %>
<%= if @project.completed_at do %>
<.icon name="hero-check-circle" class="w-4 h-4 text-success" /> {gettext("Finished:")} {L10n.format_datetime(@schedule.projected_end)}
<% else %>
<.icon name="hero-clock" class="w-4 h-4 text-base-content/60" /> {gettext("Remaining:")} {rem_v} {rem_u}
·
<.icon name="hero-arrow-trending-up" class={"w-4 h-4 #{if @schedule.ahead?, do: "text-success", else: "text-error"}"} /> {gettext("ETA:")} {L10n.format_datetime(@schedule.projected_end)} {gettext("at planned pace")}
<% end %>
<% end %> <%!-- Progress bar — the card's bottom border (not for templates). --%>
<% end %> <%!-- View tabs (List / Timeline). Rendered in every context (templates excepted). The `ProjectTabsUrl` phx-hook — which syncs the URL on push/popstate — is attached ONLY when `@tab_url_sync?` (the standalone admin page; off by default for embeds so they never touch the host's URL). The tabs switch instantly with or without the hook. --%>
<.nav_tabs active_tab={to_string(@active_tab)} on_change="switch_tab" tabs={[ %{id: "list", label: gettext("List"), icon: "hero-list-bullet"}, %{id: "gantt", label: gettext("Timeline"), icon: "hero-chart-bar-square"} ]} />
<%!-- List tab --%>
<%!-- 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")} <% else %>
<%!-- Vertical connector line --%>
<%!-- 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. --%>
<%= for {a, idx} <- Enum.with_index(@assignments) do %>
<%!-- Status dot on the timeline --%>
<%= if a.status == "done" do %> <.icon name="hero-check" class="w-5 h-5" /> <% else %> {idx + 1} <% end %>
<%!-- Card --%>
<%= if Assignment.subproject?(a) do %>
<% 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 --%>
<.icon name="hero-bars-3" class="w-4 h-4" /> <.icon name="hero-folder" class="w-3 h-3" /> {gettext("Sub-project")} <.assignment_status_badge :if={not @is_template} status={a.status} /> {Project.localized_name(child, sp_lang)}
<.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")} <.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" />
<%!-- Sub-project description --%> <% sp_desc = Project.localized_description(child, sp_lang) %>
{sp_desc}
<%!-- Rolled-up meta (read-only — driven by the child) --%>
<.icon name="hero-rectangle-stack" class="w-3 h-3" /> {gettext("%{done}/%{total} tasks", done: sp_summary.done, total: sp_summary.total)} <% {sp_hv, sp_hu} = humanize_hours(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}
{a.progress_pct}%
<.icon name="hero-user" class="w-3 h-3" /> {assignee_type(child)}: {assignee_label(child)}
<%!-- This sub-project's own dependencies (on siblings) --%> <% sp_deps = Map.get(@deps_by_assignment, a.uuid, []) %>
<%= for dep <- sp_deps do %> <.icon name="hero-arrow-right-circle" class="w-3 h-3" /> {gettext("depends on:")} {Assignment.label(dep.depends_on, sp_lang)} <% end %>
<%!-- Expanded: the child's own task list + add-dependency --%>
<% child_tasks = Map.get(@subproject_child_tasks, a.uuid, []) %> <%= if child_tasks == [] do %>

{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")}

<% else %>
<%= 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). --%>
<.assignment_status_badge status={ct.status} /> {gettext("Sub-project")} <.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)} {ct.progress_pct}%
<% else %> <%!-- Same task card as the top-level timeline, just inset. --%>
<%= if ct.status == "done" do %> <.icon name="hero-check" class="w-4 h-4" /> <% else %> {ci + 1} <% end %>
<.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} />
<% end %> <% end %>
<% end %>
<% 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 %>
<% end %>
<% end %>
<%!-- Gantt tab — rendered in every context (templates excepted), lazy-mounted on first activation and then kept (so its own zoom/expand survive switching back). It's a nested LiveView with its own PubSub/state — when the show page is itself embedded this is a nested-within-nested `live_render`, which is fine (server-rendered SVG; only the popover/auto-scroll need the gantt JS hooks in the host). `headless` drops its back-link since the tabs replace it. --%>
<%= if @gantt_mounted? do %> {live_render(@socket, PhoenixKitProjects.Web.ProjectGanttLive, id: "project-gantt-live-#{@project.uuid}", session: %{ "id" => @project.uuid, "headless" => true, # No wrapper padding — the show page already pads this content area; # the gantt's own `px-4 py-6` would double it and push the chart down. "wrapper_class" => "", "locale" => L10n.current_content_lang(), # Forward the viewer so the nested gantt reconstructs the same # user/scope across this second `live_render` hop (see # `WebHelpers.assign_embed_user/2`). Bracket access (not `@`) so # an off-router mount missing the assign degrades to nil rather # than raising — matches the module's no-bang-form convention. "current_user_uuid" => assigns[:phoenix_kit_current_user] && assigns[:phoenix_kit_current_user].uuid })} <% 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 %> <% 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 %> <% end %>
""" end end