mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): copyable task-id chip + stable, non-shifting task header
The task header rendered 'Task #<uuid>: <title>' as one click-to-edit <h1>, so the UUID could not be selected/copied (clicking it entered title-edit) and the editable field silently dropped the id. Worse, the title + status + team + type all shared one flex-wrap row with auto-width dropdowns, so a long title or a wider selected label shoved the controls — and the Actions button — to new positions on every render. Restructure for stability: - Title is its own row, editable (no UUID), and truncates on overflow — it can never push the controls or Actions. - A read-only #<short-id> chip with a copy button (reuses CopyButton, which has the LAN/http clipboard fallback) copies the FULL uuid. - Status and team dropdowns are fixed-width (w-40 / w-36), so changing the selected value's label width can't shift a neighbor. - Actions is pinned top-right (shrink-0) and never moves regardless of title length or dropdown contents. panel typecheck + eslint clean.
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
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 {
|
||||
useDeleteTask,
|
||||
useUpdateTask,
|
||||
useTaskValidTransitions,
|
||||
} from "@/hooks/use-tasks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -50,24 +54,40 @@ import {
|
||||
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, 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_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",
|
||||
[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, string> = {
|
||||
@@ -99,13 +119,14 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
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);
|
||||
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
|
||||
(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
|
||||
@@ -114,7 +135,7 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
// 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)
|
||||
(s) => s !== task.status && !nextStatuses.includes(s),
|
||||
);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
@@ -242,79 +263,186 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
|
||||
// Determine available lifecycle actions based on current status
|
||||
const getAvailableActions = () => {
|
||||
const actions: Array<{ label: string; action: string; icon?: React.ReactNode }> = [];
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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: "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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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" /> });
|
||||
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:
|
||||
actions.push({ label: "Approve & Merge", action: "approve-and-merge", icon: <ThumbsUp className="h-4 w-4 mr-2" /> });
|
||||
actions.push({ label: "Request Changes", action: "ceo-reject", icon: <ThumbsDown className="h-4 w-4 mr-2" /> });
|
||||
actions.push({
|
||||
label: "Approve & Merge",
|
||||
action: "approve-and-merge",
|
||||
icon: <ThumbsUp className="h-4 w-4 mr-2" />,
|
||||
});
|
||||
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" /> });
|
||||
actions.push({
|
||||
label: "Reopen Task",
|
||||
action: "reopen",
|
||||
icon: <Play className="h-4 w-4 mr-2" />,
|
||||
});
|
||||
break;
|
||||
case TaskStatus.BACKLOG:
|
||||
actions.push({ label: "Activate Task", action: "activate", icon: <Play className="h-4 w-4 mr-2" /> });
|
||||
actions.push({
|
||||
label: "Activate Task",
|
||||
action: "activate",
|
||||
icon: <Play className="h-4 w-4 mr-2" />,
|
||||
});
|
||||
break;
|
||||
case TaskStatus.NEEDS_REVISION:
|
||||
actions.push({ label: "Start Revision", action: "start-revision", icon: <Play className="h-4 w-4 mr-2" /> });
|
||||
actions.push({
|
||||
label: "Start Revision",
|
||||
action: "start-revision",
|
||||
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" /> });
|
||||
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" />,
|
||||
});
|
||||
}
|
||||
|
||||
// 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" /> });
|
||||
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" />,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -323,154 +451,185 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
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">
|
||||
<div className="border-b pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* 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. */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<Link href="/tasks">
|
||||
<Button variant="ghost" size="icon" className="shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Row 1: title only — editable, no UUID. Truncates on overflow. */}
|
||||
{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>
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<h1
|
||||
className="text-2xl font-bold cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors"
|
||||
className="text-2xl font-bold cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors truncate"
|
||||
onClick={startEditingTitle}
|
||||
title="Click to edit"
|
||||
title={task.title}
|
||||
>
|
||||
Task #{task.id}: {task.title}
|
||||
{task.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Status Dropdown — only current status + valid next statuses from backend */}
|
||||
<Select value={task.status} onValueChange={(v) => handleStatusChange(v as TaskStatus)}>
|
||||
<SelectTrigger
|
||||
className={`w-auto h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
|
||||
disabled={isTransitionsLoading}
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border bg-muted/40 px-2 py-0.5 font-mono text-xs text-muted-foreground"
|
||||
title={task.id}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 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) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className={`px-2 py-0.5 rounded ${statusColors[status]}`}>
|
||||
{statusLabels[status]}
|
||||
#{task.id.slice(0, 8)}
|
||||
<CopyButton value={task.id} className="-mr-1 px-1 py-0" />
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
|
||||
{/* Status Dropdown — only current status + valid next statuses from backend */}
|
||||
<Select
|
||||
value={task.status}
|
||||
onValueChange={(v) => handleStatusChange(v as TaskStatus)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`w-40 shrink-0 h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
|
||||
disabled={isTransitionsLoading}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 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>
|
||||
))}
|
||||
{/* God-mode: every remaining status as an audited admin
|
||||
{/* nextStatuses sourced exclusively from useTaskValidTransitions
|
||||
(GET /tasks/{id}/valid-transitions) — no local fallback array */}
|
||||
{nextStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded ${statusColors[status]}`}
|
||||
>
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* God-mode: every remaining status as an audited admin
|
||||
override (no valid in-band transition). Marked "force" so
|
||||
the operator knows it bypasses the normal lifecycle. */}
|
||||
{overrideStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className={`px-2 py-0.5 rounded ${statusColors[status]}`}>
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
force
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{overrideStatuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded ${statusColors[status]}`}
|
||||
>
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
force
|
||||
</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>
|
||||
{/* Team Dropdown */}
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<Select
|
||||
value={task.team}
|
||||
onValueChange={(v) => handleTeamChange(v as Team)}
|
||||
>
|
||||
<SelectTrigger className="w-36 shrink-0 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") && (
|
||||
{/* Task Type Badge */}
|
||||
{task.task_type && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAction("cancel")}
|
||||
className="text-orange-600"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Cancel Task
|
||||
</DropdownMenuItem>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="shrink-0">
|
||||
<TaskTypeBadge type={task.task_type} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete option */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{/* Actions — pinned top-right; never moves regardless of title length
|
||||
or a dropdown's selected-label width. */}
|
||||
<div className="shrink-0">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
@@ -479,7 +638,8 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{task.title}". This action cannot be undone.
|
||||
This will permanently delete "{task.title}". This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user