mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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 & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user