2026-04-20 15:10:54 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { Task, TaskStatus, Team } from "@/types";
|
2026-06-14 08:06:26 +02:00
|
|
|
import { useDeleteTask, useUpdateTask, useTaskValidTransitions } from "@/hooks/use-tasks";
|
2026-04-20 15:10:54 +02:00
|
|
|
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,
|
2026-06-14 08:06:26 +02:00
|
|
|
GitMerge,
|
2026-04-20 15:10:54 +02:00
|
|
|
GitPullRequest,
|
|
|
|
|
FileCheck,
|
|
|
|
|
Send,
|
|
|
|
|
ThumbsUp,
|
|
|
|
|
ThumbsDown,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { TaskTypeBadge } from "../task-type-badge";
|
|
|
|
|
|
|
|
|
|
// Status badge colors
|
|
|
|
|
const statusColors: Record<TaskStatus, string> = {
|
|
|
|
|
[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_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, string> = {
|
|
|
|
|
[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_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();
|
2026-06-14 08:06:26 +02:00
|
|
|
// Fetch valid next statuses from GET /tasks/{id}/valid-transitions.
|
|
|
|
|
// Falls back to [] while loading or on error — the Select is disabled during loading.
|
2026-06-14 13:56:23 +02:00
|
|
|
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
|
|
|
|
|
);
|
2026-04-20 15:10:54 +02:00
|
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Inline editing states
|
|
|
|
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
|
|
|
const [localTitleValue, setLocalTitleValue] = useState("");
|
|
|
|
|
const titleInputRef = useRef<HTMLInputElement>(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;
|
|
|
|
|
|
|
|
|
|
// Backend requires lifecycle actions for ALL status changes
|
|
|
|
|
// Map target status to the action that achieves it
|
|
|
|
|
const statusToAction: Partial<Record<TaskStatus, string>> = {
|
|
|
|
|
[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];
|
|
|
|
|
if (action && onAction) {
|
|
|
|
|
onAction(action);
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(`Cannot transition to ${statusLabels[newStatus]} from current status`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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: <Play className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.CLAIMED:
|
|
|
|
|
actions.push({ label: "Start Work", action: "start", icon: <Play className="h-4 w-4 mr-2" /> });
|
|
|
|
|
// 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: <GitBranch className="h-4 w-4 mr-2" /> });
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.IN_PROGRESS:
|
|
|
|
|
actions.push({ label: "Pause", action: "pause", icon: <Pause className="h-4 w-4 mr-2" /> });
|
|
|
|
|
actions.push({ label: "Mark Blocked", action: "block", icon: <AlertTriangle className="h-4 w-4 mr-2" /> });
|
|
|
|
|
// 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: <GitPullRequest className="h-4 w-4 mr-2" /> });
|
|
|
|
|
}
|
|
|
|
|
actions.push({ label: "Self Verify", action: "verify", icon: <CheckCircle className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.BLOCKED:
|
|
|
|
|
actions.push({ label: "Unblock", action: "unblock", icon: <Play className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.PAUSED:
|
|
|
|
|
actions.push({ label: "Resume", action: "resume", icon: <Play className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.VERIFYING:
|
|
|
|
|
actions.push({ label: "Submit for QA", action: "submit-qa", icon: <CheckCircle className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.AWAITING_QA:
|
|
|
|
|
actions.push({ label: "Pass QA", action: "pass-qa", icon: <CheckCircle className="h-4 w-4 mr-2" /> });
|
|
|
|
|
actions.push({ label: "Fail QA", action: "fail-qa", icon: <XCircle className="h-4 w-4 mr-2" /> });
|
|
|
|
|
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: <FileCheck className="h-4 w-4 mr-2" /> });
|
|
|
|
|
}
|
|
|
|
|
// 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: <Send className="h-4 w-4 mr-2" /> });
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.AWAITING_PM_REVIEW:
|
|
|
|
|
actions.push({ label: "Approve & Complete", action: "complete", icon: <ThumbsUp className="h-4 w-4 mr-2" /> });
|
|
|
|
|
actions.push({ label: "Escalate to CEO", action: "escalate-to-ceo", icon: <Send className="h-4 w-4 mr-2" /> });
|
|
|
|
|
actions.push({ label: "Request Changes", action: "request-changes", icon: <ThumbsDown className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.AWAITING_CEO_APPROVAL:
|
2026-06-14 08:06:26 +02:00
|
|
|
actions.push({ label: "Approve & Merge", action: "approve-and-merge", icon: <ThumbsUp className="h-4 w-4 mr-2" /> });
|
2026-04-20 15:10:54 +02:00
|
|
|
actions.push({ label: "Request Changes", action: "ceo-reject", icon: <ThumbsDown className="h-4 w-4 mr-2" /> });
|
|
|
|
|
break;
|
|
|
|
|
case TaskStatus.CANCELLED:
|
|
|
|
|
actions.push({ label: "Reopen Task", action: "reopen", icon: <Play className="h-4 w-4 mr-2" /> });
|
|
|
|
|
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: <XCircle className="h-4 w-4 mr-2" /> });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 08:06:26 +02:00
|
|
|
// 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: <GitMerge className="h-4 w-4 mr-2" /> });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 15:10:54 +02:00
|
|
|
return actions;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const actions = getAvailableActions();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between border-b pb-4">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<Link href="/tasks">
|
|
|
|
|
<Button variant="ghost" size="icon">
|
|
|
|
|
<ArrowLeft className="h-5 w-5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</Link>
|
|
|
|
|
<div>
|
|
|
|
|
{/* Title, Status, and Team - all on same row */}
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
{editingTitle ? (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
ref={titleInputRef}
|
|
|
|
|
value={titleValue}
|
|
|
|
|
onChange={(e) => setTitleValue(e.target.value)}
|
|
|
|
|
onKeyDown={handleTitleKeyDown}
|
|
|
|
|
onBlur={handleTitleSave}
|
|
|
|
|
className="text-2xl font-bold h-auto py-1 px-2 w-full"
|
|
|
|
|
disabled={updateTask.isPending}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<h1
|
|
|
|
|
className="text-2xl font-bold cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors"
|
|
|
|
|
onClick={startEditingTitle}
|
|
|
|
|
title="Click to edit"
|
|
|
|
|
>
|
|
|
|
|
Task #{task.id}: {task.title}
|
|
|
|
|
</h1>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-14 08:06:26 +02:00
|
|
|
{/* Status Dropdown — only current status + valid next statuses from backend */}
|
2026-04-20 15:10:54 +02:00
|
|
|
<Select value={task.status} onValueChange={(v) => handleStatusChange(v as TaskStatus)}>
|
2026-06-14 08:06:26 +02:00
|
|
|
<SelectTrigger
|
|
|
|
|
className={`w-auto h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
|
|
|
|
|
disabled={isTransitionsLoading}
|
|
|
|
|
>
|
2026-04-20 15:10:54 +02:00
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-06-14 08:06:26 +02:00
|
|
|
{/* Always render the current status first so the trigger value is always present */}
|
|
|
|
|
<SelectItem key={task.status} value={task.status}>
|
|
|
|
|
<span className={`px-2 py-0.5 rounded ${statusColors[task.status]}`}>
|
|
|
|
|
{statusLabels[task.status]}
|
|
|
|
|
</span>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
{/* nextStatuses sourced exclusively from useTaskValidTransitions
|
|
|
|
|
(GET /tasks/{id}/valid-transitions) — no local fallback array */}
|
|
|
|
|
{nextStatuses.map((status) => (
|
2026-04-20 15:10:54 +02:00
|
|
|
<SelectItem key={status} value={status}>
|
|
|
|
|
<span className={`px-2 py-0.5 rounded ${statusColors[status]}`}>
|
|
|
|
|
{statusLabels[status]}
|
|
|
|
|
</span>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
{/* Team Dropdown - same row */}
|
|
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
|
|
<Select value={task.team} onValueChange={(v) => handleTeamChange(v as Team)}>
|
|
|
|
|
<SelectTrigger className="w-auto h-7 text-sm text-muted-foreground border-0 bg-transparent hover:bg-muted/50 px-2">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.values(Team).map((team) => (
|
|
|
|
|
<SelectItem key={team} value={team}>
|
|
|
|
|
{team.replace(/_/g, " ")} Team
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
{/* Task Type Badge */}
|
|
|
|
|
{task.task_type && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
|
|
<TaskTypeBadge type={task.task_type} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Actions Menu */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{actions.length > 0 && (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button variant="outline">
|
|
|
|
|
Actions
|
|
|
|
|
<MoreVertical className="h-4 w-4 ml-2" />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
{/* Lifecycle actions (non-cancel) */}
|
|
|
|
|
{actions
|
|
|
|
|
.filter((a) => a.action !== "cancel")
|
|
|
|
|
.map((action) => (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={action.action}
|
|
|
|
|
onClick={() => handleAction(action.action)}
|
|
|
|
|
>
|
|
|
|
|
{action.icon}
|
|
|
|
|
{action.label}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* Cancel action */}
|
|
|
|
|
{actions.some((a) => a.action === "cancel") && (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => handleAction("cancel")}
|
|
|
|
|
className="text-orange-600"
|
|
|
|
|
>
|
|
|
|
|
<XCircle className="h-4 w-4 mr-2" />
|
|
|
|
|
Cancel Task
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Delete option */}
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => setDeleteOpen(true)}
|
|
|
|
|
className="text-red-600"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
|
|
|
Delete Task
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Delete Confirmation */}
|
|
|
|
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>Delete Task?</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
This will permanently delete "{task.title}". This action cannot be undone.
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={handleDelete}
|
|
|
|
|
className="bg-red-600 hover:bg-red-700"
|
|
|
|
|
>
|
|
|
|
|
{deleteTask.isPending ? "Deleting..." : "Delete"}
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|