fix(panel): surface the CEO "Approve & Start" gate so it can't be missed (#119)

After the Board reviews a task, the orchestrator sets board_review_complete,
notifies the CEO, and leaves the task PENDING (that pending state is what
drives Main PM dispatch on approval). But the panel never surfaced this:

- The task-page "Approve & Start" button was gated on team===BOARD plus a
  product-scoped predicate (no project_id, has product_id). A project-scoped
  intake task carries a project_id, team=its lead cell, and no product_id, so
  the button rendered nowhere and the CEO had no way to approve it.
- The dashboard CEO Approval Queue only queried awaiting_ceo_approval, so a
  board-reviewed pending task showed up nowhere on the dashboard either.

Fixes:
- Gate the task-page button on the orchestrator's own criterion: pending +
  board_review_complete, and not yet handed to Main PM (team !== main_pm).
- Add a "Ready to start · board reviewed" section to the dashboard CEO
  Approval Queue listing pending + board_review_complete tasks, each with an
  Approve & Start action, with the header count covering both stages.

Both exclude team === main_pm because approve_and_start re-targets the task to
the Main PM without changing status — otherwise an already-approved task would
never leave the queue.
This commit is contained in:
Corey Dean
2026-06-12 16:07:07 +02:00
committed by Renn F
parent 40780ff7cd
commit 048e7ecf6f
2 changed files with 178 additions and 83 deletions
@@ -361,23 +361,22 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
{/* Header */} {/* Header */}
<TaskHeader task={task} onAction={handleAction} /> <TaskHeader task={task} onAction={handleAction} />
{/* CEO gate #1: Approve & Start a board-reviewed coordination task. {/* CEO gate #1: Approve & Start a board-reviewed task. After the Board
This is the handoff for a board/fan-out task (a product, no repo of its finishes, the orchestrator sets board_review_complete and sends the
own) that the Board has reviewed and is waiting on the CEO to hand to CEO an approval notification, but the task STAYS pending (its pending
Main PM. The server's approve_and_start keeps the task PENDING (it state is what drives Main PM dispatch on approval) — so we gate on
re-targets to Main PM without a status change — that pending state is PENDING here, NOT on awaiting_ceo_approval (the unrelated end-of-work
what drives Main PM dispatch), so we gate on PENDING here, NOT on ceo-approve flow). Gate on exactly the orchestrator's own criterion:
awaiting_ceo_approval (the unrelated end-of-work ceo-approve flow). pending + board_review_complete. The earlier team===BOARD / product-
The button must not appear until the board has actually finished scoped predicate was too narrow — it hid this button for project-
reviewing, so we also require board_review_complete — the flag the scoped intake tasks (which carry a project_id, team=the lead cell,
orchestrator sets once BOTH the PO and Head of Marketing are done. The and no product_id), leaving the CEO with no way to approve them.
coordination predicate (no project_id, has product_id) keeps the approve_and_start re-targets the task to the Main PM (team → main_pm)
button to the board's fan-out handoffs, not every board-team task. */} without changing status, so exclude team===MAIN_PM to hide the button
once it's been approved. */}
{task.status === TaskStatus.PENDING && {task.status === TaskStatus.PENDING &&
task.team === Team.BOARD && task.board_review_complete === true &&
!task.project_id && task.team !== Team.MAIN_PM && (
!!task.product_id &&
task.board_review_complete === true && (
<div className="flex justify-end"> <div className="flex justify-end">
<ApproveAndStartButton task={task} /> <ApproveAndStartButton task={task} />
</div> </div>
@@ -17,9 +17,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Clock, FileText, ExternalLink } from "lucide-react"; import { CheckCircle2, XCircle, Clock, FileText, ExternalLink, Rocket } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import type { Task } from "@/types"; import { TaskStatus, Team, type Task } from "@/types";
import { toast } from "sonner"; import { toast } from "sonner";
interface CeoApprovalQueueProps { interface CeoApprovalQueueProps {
@@ -29,16 +29,35 @@ interface CeoApprovalQueueProps {
export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) { export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [actionType, setActionType] = useState<"approve" | "reject" | null>(null); const [actionType, setActionType] = useState<"approve" | "reject" | "start" | null>(null);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
// Fetch tasks awaiting CEO approval // Fetch tasks awaiting CEO approval (the end-of-work, pre-merge gate)
const { data: tasks, isLoading } = useQuery({ const { data: tasks, isLoading } = useQuery({
queryKey: ["tasks", "awaiting-ceo-approval"], queryKey: ["tasks", "awaiting-ceo-approval"],
queryFn: () => tasksApi.getAwaitingCeoApproval(), queryFn: () => tasksApi.getAwaitingCeoApproval(),
refetchInterval: 30000, // Refresh every 30 seconds refetchInterval: 30000, // Refresh every 30 seconds
}); });
// Fetch tasks waiting on the CEO's Approve & Start (board review done, still
// PENDING). The orchestrator sets board_review_complete + notifies the CEO
// but leaves the task pending, so it never appears in the awaiting-ceo list.
// Surface it here, or the CEO has no idea a task is waiting on them.
//
// approve_and_start does NOT change status — it re-targets the task to the
// Main PM (team → main_pm). So exclude team === MAIN_PM, otherwise an
// already-approved task stays in this list forever.
const { data: startTasks } = useQuery({
queryKey: ["tasks", "awaiting-approve-start"],
queryFn: async () => {
const pending = await tasksApi.list({ status: TaskStatus.PENDING });
return pending.filter(
(t) => t.board_review_complete === true && t.team !== Team.MAIN_PM,
);
},
refetchInterval: 30000,
});
// Approve mutation // Approve mutation
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: ({ taskId, notes }: { taskId: string; notes?: string }) => mutationFn: ({ taskId, notes }: { taskId: string; notes?: string }) =>
@@ -67,7 +86,21 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
}, },
}); });
const openDialog = (task: Task, action: "approve" | "reject") => { // Approve & Start mutation — hands a board-reviewed task to the Main PM.
const approveStartMutation = useMutation({
mutationFn: ({ taskId, notes }: { taskId: string; notes: string }) =>
tasksApi.approveAndStart(taskId, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Task approved and handed to Main PM");
closeDialog();
},
onError: (error) => {
toast.error(`Failed to approve & start: ${error instanceof Error ? error.message : "Unknown error"}`);
},
});
const openDialog = (task: Task, action: "approve" | "reject" | "start") => {
setSelectedTask(task); setSelectedTask(task);
setActionType(action); setActionType(action);
setNotes(""); setNotes("");
@@ -90,6 +123,13 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
return; return;
} }
approveMutation.mutate({ taskId: selectedTask.id, notes: notes.trim() }); approveMutation.mutate({ taskId: selectedTask.id, notes: notes.trim() });
} else if (actionType === "start") {
// Server requires substantive approval notes (>= 20 chars).
if (notes.trim().length < 20) {
toast.error("Approval notes are required (>= 20 characters)");
return;
}
approveStartMutation.mutate({ taskId: selectedTask.id, notes: notes.trim() });
} else if (actionType === "reject") { } else if (actionType === "reject") {
if (!notes.trim()) { if (!notes.trim()) {
toast.error("Rejection reason is required"); toast.error("Rejection reason is required");
@@ -132,6 +172,68 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
} }
const pendingTasks = tasks || []; const pendingTasks = tasks || [];
const readyToStart = startTasks || [];
const totalCount = pendingTasks.length + readyToStart.length;
const renderRow = (task: Task, kind: "start" | "approve") => (
<div
key={task.id}
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getPriorityBadge(task.priority)}
<Badge variant="outline">{task.team}</Badge>
</div>
<Link
href={`/tasks/${task.id}`}
className="font-medium hover:underline line-clamp-1"
>
{task.title}
</Link>
{task.quick_context && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.quick_context}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
<Link href={`/tasks/${task.id}`}>
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
</Button>
</Link>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => openDialog(task, "reject")}
>
<XCircle className="h-4 w-4 mr-1" />
Reject
</Button>
{kind === "start" ? (
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700"
onClick={() => openDialog(task, "start")}
>
<Rocket className="h-4 w-4 mr-1" />
Approve &amp; Start
</Button>
) : (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => openDialog(task, "approve")}
>
<CheckCircle2 className="h-4 w-4 mr-1" />
Approve
</Button>
)}
</div>
</div>
);
return ( return (
<> <>
@@ -140,70 +242,38 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" /> <Clock className="h-5 w-5" />
CEO Approval Queue CEO Approval Queue
{pendingTasks.length > 0 && ( {totalCount > 0 && (
<Badge variant="secondary" className="ml-2"> <Badge variant="secondary" className="ml-2">
{pendingTasks.length} {totalCount}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>
<CardDescription>Tasks escalated for your final approval</CardDescription> <CardDescription>Tasks waiting on your decision</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{pendingTasks.length === 0 ? ( {totalCount === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-2 opacity-50" /> <CheckCircle2 className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No tasks awaiting approval</p> <p>No tasks awaiting approval</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-5">
{pendingTasks.map((task) => ( {readyToStart.length > 0 && (
<div <div className="space-y-3">
key={task.id} <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors" Ready to start · board reviewed
> </p>
<div className="flex-1 min-w-0"> {readyToStart.map((task) => renderRow(task, "start"))}
<div className="flex items-center gap-2 mb-1">
{getPriorityBadge(task.priority)}
<Badge variant="outline">{task.team}</Badge>
</div>
<Link
href={`/tasks/${task.id}`}
className="font-medium hover:underline line-clamp-1"
>
{task.title}
</Link>
{task.quick_context && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.quick_context}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
<Link href={`/tasks/${task.id}`}>
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
</Button>
</Link>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => openDialog(task, "reject")}
>
<XCircle className="h-4 w-4 mr-1" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => openDialog(task, "approve")}
>
<CheckCircle2 className="h-4 w-4 mr-1" />
Approve
</Button>
</div>
</div> </div>
))} )}
{pendingTasks.length > 0 && (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Final approval · work complete
</p>
{pendingTasks.map((task) => renderRow(task, "approve"))}
</div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -214,12 +284,18 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{actionType === "approve" ? "Approve Task" : "Reject Task"} {actionType === "approve"
? "Approve Task"
: actionType === "start"
? "Approve & Start Task"
: "Reject Task"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{actionType === "approve" {actionType === "approve"
? "This will complete the task and notify the team." ? "This will complete the task and notify the team."
: "This will send the task back for revision."} : actionType === "start"
? "This hands the task to the Main PM to delegate to the cells and begin work."
: "This will send the task back for revision."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -247,14 +323,20 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notes"> <Label htmlFor="notes">
{actionType === "approve" ? "Notes (optional)" : "Reason for rejection (required)"} {actionType === "reject"
? "Reason for rejection (required)"
: actionType === "start"
? "Approval notes (required, ≥ 20 characters)"
: "Notes (optional)"}
</Label> </Label>
<Textarea <Textarea
id="notes" id="notes"
placeholder={ placeholder={
actionType === "approve" actionType === "reject"
? "Add any notes about this approval..." ? "Explain what needs to be fixed..."
: "Explain what needs to be fixed..." : actionType === "start"
? "Why this is ready to build, scope to hold to, anything the Main PM should know..."
: "Add any notes about this approval..."
} }
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
@@ -268,15 +350,29 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
</Button> </Button>
<Button <Button
onClick={handleConfirm} onClick={handleConfirm}
disabled={approveMutation.isPending || rejectMutation.isPending} disabled={
className={actionType === "approve" ? "bg-green-600 hover:bg-green-700" : ""} approveMutation.isPending ||
rejectMutation.isPending ||
approveStartMutation.isPending
}
className={
actionType === "approve"
? "bg-green-600 hover:bg-green-700"
: actionType === "start"
? "bg-blue-600 hover:bg-blue-700"
: ""
}
variant={actionType === "reject" ? "destructive" : "default"} variant={actionType === "reject" ? "destructive" : "default"}
> >
{approveMutation.isPending || rejectMutation.isPending {approveMutation.isPending ||
rejectMutation.isPending ||
approveStartMutation.isPending
? "Processing..." ? "Processing..."
: actionType === "approve" : actionType === "approve"
? "Approve & Complete" ? "Approve & Complete"
: "Reject & Request Revision"} : actionType === "start"
? "Approve & Start"
: "Reject & Request Revision"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>