"use client"; import { useState, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Task, TaskStatus, Team } from "@/types"; import { useDeleteTask, useUpdateTask, useTaskValidTransitions, } from "@/hooks/use-tasks"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { ArrowLeft, MoreVertical, Play, Pause, CheckCircle, XCircle, AlertTriangle, Trash2, GitBranch, GitMerge, GitPullRequest, FileCheck, Send, ThumbsUp, ThumbsDown, } from "lucide-react"; import { toast } from "sonner"; import Link from "next/link"; import { TaskTypeBadge } from "../task-type-badge"; import { CopyButton } from "@/components/ui/copy-button"; // Status badge colors const statusColors: Record = { [TaskStatus.BACKLOG]: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400", [TaskStatus.PENDING]: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", [TaskStatus.CLAIMED]: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", [TaskStatus.IN_PROGRESS]: "bg-blue-200 text-blue-800 dark:bg-blue-800 dark:text-blue-200", [TaskStatus.BLOCKED]: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", [TaskStatus.PAUSED]: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300", [TaskStatus.VERIFYING]: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300", [TaskStatus.NEEDS_REVISION]: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300", [TaskStatus.AWAITING_QA]: "bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200", [TaskStatus.AWAITING_DOCUMENTATION]: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300", [TaskStatus.AWAITING_PR_REVIEW]: "bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300", [TaskStatus.AWAITING_PM_REVIEW]: "bg-orange-200 text-orange-800 dark:bg-orange-800 dark:text-orange-200", [TaskStatus.AWAITING_CEO_APPROVAL]: "bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200", [TaskStatus.COMPLETED]: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", [TaskStatus.CANCELLED]: "bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400", }; const statusLabels: Record = { [TaskStatus.BACKLOG]: "Backlog", [TaskStatus.PENDING]: "Pending", [TaskStatus.CLAIMED]: "Claimed", [TaskStatus.IN_PROGRESS]: "In Progress", [TaskStatus.BLOCKED]: "Blocked", [TaskStatus.PAUSED]: "Paused", [TaskStatus.VERIFYING]: "Verifying", [TaskStatus.NEEDS_REVISION]: "Needs Revision", [TaskStatus.AWAITING_QA]: "Awaiting QA", [TaskStatus.AWAITING_DOCUMENTATION]: "Awaiting Docs", [TaskStatus.AWAITING_PR_REVIEW]: "PR Review", [TaskStatus.AWAITING_PM_REVIEW]: "PM Review", [TaskStatus.AWAITING_CEO_APPROVAL]: "CEO Approval", [TaskStatus.COMPLETED]: "Completed", [TaskStatus.CANCELLED]: "Cancelled", }; interface TaskHeaderProps { task: Task; onAction?: (action: string) => void; } export function TaskHeader({ task, onAction }: TaskHeaderProps) { const router = useRouter(); const deleteTask = useDeleteTask(); const updateTask = useUpdateTask(); // Fetch valid next statuses from GET /tasks/{id}/valid-transitions. // Falls back to [] while loading or on error — the Select is disabled during loading. const { data: validTransitionsData, isLoading: isTransitionsLoading } = useTaskValidTransitions(task.id, task.status); // Exclude the current status: it is always rendered first (below), so a stale // cache or a backend list that re-includes it would duplicate the item. Radix // Select requires unique item values, so a duplicate also garbles the trigger // label (it renders as e.g. "Completed Completed"). const nextStatuses: TaskStatus[] = (validTransitionsData ?? []).filter( (s) => s !== task.status, ); // God-mode: the panel always acts as the CEO/operator, so the dropdown also // offers every OTHER status as a forced admin override — letting the CEO // recover a task wedged in a state with no valid in-band move (e.g. a task // stuck in `blocked` whose PR can never merge, or reopening a `cancelled` // task). These route through the audited admin-override path, not lifecycle // verbs. See handleStatusChange. const overrideStatuses: TaskStatus[] = Object.values(TaskStatus).filter( (s) => s !== task.status && !nextStatuses.includes(s), ); const [deleteOpen, setDeleteOpen] = useState(false); // Inline editing states const [editingTitle, setEditingTitle] = useState(false); const [localTitleValue, setLocalTitleValue] = useState(""); const titleInputRef = useRef(null); // Display prop value when not editing, local value when editing const titleValue = editingTitle ? localTitleValue : task.title; const setTitleValue = (value: string) => setLocalTitleValue(value); // Start editing - copy current prop value to local state const startEditingTitle = () => { setLocalTitleValue(task.title); setEditingTitle(true); }; // Focus input when editing starts useEffect(() => { if (editingTitle && titleInputRef.current) { titleInputRef.current.focus(); titleInputRef.current.select(); } }, [editingTitle]); const handleAction = (action: string) => { onAction?.(action); }; const handleDelete = async () => { try { await deleteTask.mutateAsync(task.id); toast.success("Task deleted"); setDeleteOpen(false); router.push("/tasks"); } catch { toast.error("Failed to delete task"); } }; const handleTitleSave = async () => { if (!titleValue.trim() || titleValue === task.title) { setTitleValue(task.title); setEditingTitle(false); return; } try { await updateTask.mutateAsync({ taskId: task.id, updates: { title: titleValue.trim() }, }); setEditingTitle(false); } catch { toast.error("Failed to update title"); setTitleValue(task.title); } }; const handleTitleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { handleTitleSave(); } else if (e.key === "Escape") { setTitleValue(task.title); setEditingTitle(false); } }; const handleStatusChange = async (newStatus: TaskStatus) => { // Skip if same status if (newStatus === task.status) return; // Map a target status to the lifecycle action that achieves it in-band. const statusToAction: Partial> = { [TaskStatus.PENDING]: "reopen", // From cancelled [TaskStatus.CLAIMED]: "claim", [TaskStatus.IN_PROGRESS]: "start", [TaskStatus.BLOCKED]: "block", [TaskStatus.PAUSED]: "pause", [TaskStatus.VERIFYING]: "verify", [TaskStatus.AWAITING_QA]: "submit-qa", [TaskStatus.AWAITING_DOCUMENTATION]: "pass-qa", [TaskStatus.COMPLETED]: "complete", [TaskStatus.CANCELLED]: "cancel", }; const action = statusToAction[newStatus]; // Prefer the real lifecycle action when this is a valid in-band transition — // it runs the proper side effects (e.g. `complete` merges the PR). if (action && nextStatuses.includes(newStatus) && onAction) { onAction(action); return; } // God-mode override: force ANY status, even from a state with no valid // in-band move (a task wedged in `blocked` whose PR can never merge, or // reopening a `cancelled` task). Audited via PATCH /tasks/{id} {status} -> // admin_set_status. No PR merge / lifecycle side effects fire — this is a // pure, operator-driven state correction the CEO is entitled to make. try { await updateTask.mutateAsync({ taskId: task.id, updates: { status: newStatus }, }); toast.success(`Status forced to ${statusLabels[newStatus]}`); } catch { toast.error(`Failed to set status to ${statusLabels[newStatus]}`); } }; const handleTeamChange = async (newTeam: Team) => { if (newTeam === task.team) return; try { await updateTask.mutateAsync({ taskId: task.id, updates: { team: newTeam }, }); toast.success("Team updated"); } catch { toast.error("Failed to update team"); } }; // Determine available lifecycle actions based on current status const getAvailableActions = () => { const actions: Array<{ label: string; action: string; icon?: React.ReactNode; }> = []; switch (task.status) { case TaskStatus.PENDING: actions.push({ label: "Claim Task", action: "claim", icon: , }); break; case TaskStatus.CLAIMED: actions.push({ label: "Start Work", action: "start", icon: , }); // PM can create branch for tasks without branch (all tasks follow git workflow) if (!task.branch_name) { actions.push({ label: "Create Branch", action: "create-branch", icon: , }); } break; case TaskStatus.IN_PROGRESS: actions.push({ label: "Pause", action: "pause", icon: , }); actions.push({ label: "Mark Blocked", action: "block", icon: , }); // Create PR action for tasks with branch but no PR if (task.branch_name && !task.pr_number) { actions.push({ label: "Create PR", action: "create-pr", icon: , }); } actions.push({ label: "Self Verify", action: "verify", icon: , }); break; case TaskStatus.BLOCKED: actions.push({ label: "Unblock", action: "unblock", icon: , }); break; case TaskStatus.PAUSED: actions.push({ label: "Resume", action: "resume", icon: , }); break; case TaskStatus.VERIFYING: actions.push({ label: "Submit for QA", action: "submit-qa", icon: , }); break; case TaskStatus.AWAITING_QA: actions.push({ label: "Pass QA", action: "pass-qa", icon: , }); actions.push({ label: "Fail QA", action: "fail-qa", icon: , }); break; case TaskStatus.AWAITING_DOCUMENTATION: // Parallel phase - show status and available actions if (!task.docs_complete) { actions.push({ label: "Mark Docs Complete", action: "docs-complete", icon: , }); } // Only show submit for PM review when both docs and PR are ready if (task.docs_complete && task.pr_created) { actions.push({ label: "Submit for PM Review", action: "submit-pm-review", icon: , }); } break; case TaskStatus.AWAITING_PM_REVIEW: actions.push({ label: "Approve & Complete", action: "complete", icon: , }); actions.push({ label: "Escalate to CEO", action: "escalate-to-ceo", icon: , }); actions.push({ label: "Request Changes", action: "request-changes", icon: , }); break; case TaskStatus.AWAITING_CEO_APPROVAL: actions.push({ label: "Approve & Merge", action: "approve-and-merge", icon: , }); actions.push({ label: "Request Changes", action: "ceo-reject", icon: , }); break; case TaskStatus.CANCELLED: actions.push({ label: "Reopen Task", action: "reopen", icon: , }); break; case TaskStatus.BACKLOG: actions.push({ label: "Activate Task", action: "activate", icon: , }); break; case TaskStatus.NEEDS_REVISION: actions.push({ label: "Start Revision", action: "start-revision", icon: , }); break; } // Cancel is always available for non-terminal states if ( task.status !== TaskStatus.COMPLETED && task.status !== TaskStatus.CANCELLED ) { actions.push({ label: "Cancel Task", action: "cancel", icon: , }); } // Merge PR is available whenever task.pr_number is set and the task is not in a terminal state if ( task.pr_number && task.status !== TaskStatus.COMPLETED && task.status !== TaskStatus.CANCELLED ) { actions.push({ label: "Merge PR", action: "merge-pr", icon: , }); } return actions; }; const actions = getAvailableActions(); return (
{/* Left: back arrow + title + metadata. This column SHRINKS and the title truncates, so a long title never pushes the controls or the Actions menu out of place. */}
{/* Row 1: title only — editable, no UUID. Truncates on overflow. */} {editingTitle ? ( setTitleValue(e.target.value)} onKeyDown={handleTitleKeyDown} onBlur={handleTitleSave} className="text-2xl font-bold h-auto py-1 px-2 w-full" disabled={updateTask.isPending} /> ) : (

{task.title}

)} {/* Row 2: copyable task id + status + team + type. The dropdowns are FIXED width so changing a selected value's label width can never shift a neighbor; the id is read-only and copies the FULL uuid. */}
#{task.id.slice(0, 8)} | {/* Status Dropdown — only current status + valid next statuses from backend */} {/* Team Dropdown */} | {/* Task Type Badge */} {task.task_type && ( <> | )}
{/* Actions — pinned top-right; never moves regardless of title length or a dropdown's selected-label width. */}
{actions.length > 0 && ( {/* Lifecycle actions (non-cancel) */} {actions .filter((a) => a.action !== "cancel") .map((action) => ( handleAction(action.action)} > {action.icon} {action.label} ))} {/* Cancel action */} {actions.some((a) => a.action === "cancel") && ( <> handleAction("cancel")} className="text-orange-600" > Cancel Task )} {/* Delete option */} setDeleteOpen(true)} className="text-red-600" > Delete Task )}
{/* Delete Confirmation */} Delete Task? This will permanently delete "{task.title}". This action cannot be undone. Cancel {deleteTask.isPending ? "Deleting..." : "Delete"}
); }