mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): align task actions to server contract + collect required audit notes
The panel's human action buttons had drifted from the server request
schemas: wrong field names (qa_notes/reason vs notes), missing bodies
(cancel/complete/submit-pm-review), and a bare-string docs-complete body —
so cancel/pass-qa/fail-qa/escalate-to-ceo 4xx'd and decisions recorded no
audit note. (Agents were unaffected — they go through the gateway.)
- tasks.ts: pass-qa/fail-qa -> {notes}; escalate-to-ceo -> {notes:reason};
cancel -> {reason}; complete -> {justification}; docs-complete -> {notes};
submit-pm-review -> {notes}.
- New reusable RequiredNotesDialog (generalizes CeoApproveDialog). Every
decision action now collects a substantive note before POSTing: cancel
(>=10), pass-qa/fail-qa/docs-complete/submit-pm-review/complete (>=20),
matching the server gates. Wired in the task detail page, the actions
dropdown, and the kanban board.
Verified: pnpm tsc --noEmit and eslint both clean.
This commit is contained in:
@@ -24,6 +24,14 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { useState } from "react";
|
||||
import { KanbanCard } from "./kanban-card";
|
||||
import { RequiredNotesDialog } from "@/components/tasks/task-detail/task-action-dialogs";
|
||||
|
||||
type NotesActionKind = "pass-qa" | "fail-qa" | "complete";
|
||||
|
||||
interface PendingNotesAction {
|
||||
kind: NotesActionKind;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface ColumnConfig {
|
||||
id: string;
|
||||
@@ -55,6 +63,7 @@ export function KanbanBoard({
|
||||
const lifecycle = useTaskLifecycle();
|
||||
const updateTask = useUpdateTask();
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [pendingNotesAction, setPendingNotesAction] = useState<PendingNotesAction | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -138,23 +147,19 @@ export function KanbanBoard({
|
||||
toast.success("Task unblocked");
|
||||
break;
|
||||
case TaskStatus.AWAITING_QA:
|
||||
await lifecycle.passQa.mutateAsync({ taskId });
|
||||
toast.success("QA passed");
|
||||
break;
|
||||
setPendingNotesAction({ kind: "pass-qa", taskId });
|
||||
return; // Dialog collects the required note
|
||||
case TaskStatus.AWAITING_DOCUMENTATION:
|
||||
await lifecycle.complete.mutateAsync(taskId);
|
||||
toast.success("Task completed");
|
||||
break;
|
||||
setPendingNotesAction({ kind: "complete", taskId });
|
||||
return; // Dialog collects the required justification
|
||||
}
|
||||
break;
|
||||
case "pass-qa":
|
||||
await lifecycle.passQa.mutateAsync({ taskId });
|
||||
toast.success("QA passed");
|
||||
break;
|
||||
setPendingNotesAction({ kind: "pass-qa", taskId });
|
||||
return; // Dialog collects the required note
|
||||
case "fail-qa":
|
||||
await lifecycle.failQa.mutateAsync({ taskId });
|
||||
toast.success("QA failed - returned to developer");
|
||||
break;
|
||||
setPendingNotesAction({ kind: "fail-qa", taskId });
|
||||
return; // Dialog collects the required note
|
||||
}
|
||||
refetch();
|
||||
} catch {
|
||||
@@ -162,6 +167,77 @@ export function KanbanBoard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotesConfirm = async (text: string) => {
|
||||
if (!pendingNotesAction) return;
|
||||
const { kind, taskId } = pendingNotesAction;
|
||||
try {
|
||||
switch (kind) {
|
||||
case "pass-qa":
|
||||
await lifecycle.passQa.mutateAsync({ taskId, qaNotes: text });
|
||||
toast.success("QA passed");
|
||||
break;
|
||||
case "fail-qa":
|
||||
await lifecycle.failQa.mutateAsync({ taskId, qaNotes: text });
|
||||
toast.success("QA failed - returned to developer");
|
||||
break;
|
||||
case "complete":
|
||||
await lifecycle.complete.mutateAsync({ taskId, justification: text });
|
||||
toast.success("Task completed");
|
||||
break;
|
||||
}
|
||||
setPendingNotesAction(null);
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Action failed");
|
||||
}
|
||||
};
|
||||
|
||||
const notesDialogConfig: Record<
|
||||
NotesActionKind,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
minChars: number;
|
||||
confirmLabel: string;
|
||||
destructive?: boolean;
|
||||
isPending: boolean;
|
||||
}
|
||||
> = {
|
||||
"pass-qa": {
|
||||
title: "Pass QA",
|
||||
description:
|
||||
"Record the QA review outcome. This note is the permanent audit record and is required.",
|
||||
label: "QA notes",
|
||||
placeholder: "Verified against acceptance criteria; passing because...",
|
||||
minChars: 20,
|
||||
confirmLabel: "Pass QA",
|
||||
isPending: lifecycle.passQa.isPending,
|
||||
},
|
||||
"fail-qa": {
|
||||
title: "Fail QA",
|
||||
description:
|
||||
"Record what failed QA and what needs to change. This note is the permanent audit record and is required.",
|
||||
label: "QA notes",
|
||||
placeholder: "Failing QA because...",
|
||||
minChars: 20,
|
||||
confirmLabel: "Fail QA",
|
||||
destructive: true,
|
||||
isPending: lifecycle.failQa.isPending,
|
||||
},
|
||||
complete: {
|
||||
title: "Approve & Complete",
|
||||
description:
|
||||
"Record why this work is approved and complete. This note is the permanent audit record and is required.",
|
||||
label: "Completion justification",
|
||||
placeholder: "Approving and completing because...",
|
||||
minChars: 20,
|
||||
confirmLabel: "Approve & Complete",
|
||||
isPending: lifecycle.complete.isPending,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -225,6 +301,17 @@ export function KanbanBoard({
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{pendingNotesAction && (
|
||||
<RequiredNotesDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingNotesAction(null);
|
||||
}}
|
||||
onConfirm={handleNotesConfirm}
|
||||
{...notesDialogConfig[pendingNotesAction.kind]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { MoreHorizontal, Play, Pause, CheckCircle, XCircle, Pencil, Trash2, Clock, MessageSquare } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { EditTaskDialog } from "./edit-task-dialog";
|
||||
import { RequiredNotesDialog } from "./task-detail/task-action-dialogs";
|
||||
|
||||
interface TaskActionsProps {
|
||||
task: Task;
|
||||
@@ -44,6 +45,8 @@ export function TaskActions({
|
||||
const deleteTask = useDeleteTask();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [completeOpen, setCompleteOpen] = useState(false);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
try {
|
||||
@@ -65,12 +68,10 @@ export function TaskActions({
|
||||
toast.success("Task resumed");
|
||||
break;
|
||||
case "complete":
|
||||
await lifecycle.complete.mutateAsync(task.id);
|
||||
toast.success("Task completed");
|
||||
setCompleteOpen(true);
|
||||
break;
|
||||
case "cancel":
|
||||
await lifecycle.cancel.mutateAsync(task.id);
|
||||
toast.success("Task cancelled");
|
||||
setCancelOpen(true);
|
||||
break;
|
||||
case "reopen":
|
||||
await lifecycle.reopen.mutateAsync(task.id);
|
||||
@@ -86,6 +87,26 @@ export function TaskActions({
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async (justification: string) => {
|
||||
try {
|
||||
await lifecycle.complete.mutateAsync({ taskId: task.id, justification });
|
||||
toast.success("Task completed");
|
||||
setCompleteOpen(false);
|
||||
} catch {
|
||||
toast.error("Action failed: complete");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (reason: string) => {
|
||||
try {
|
||||
await lifecycle.cancel.mutateAsync({ taskId: task.id, reason });
|
||||
toast.success("Task cancelled");
|
||||
setCancelOpen(false);
|
||||
} catch {
|
||||
toast.error("Action failed: cancel");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteTask.mutateAsync(task.id);
|
||||
@@ -229,6 +250,35 @@ export function TaskActions({
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
||||
{/* Complete Dialog */}
|
||||
<RequiredNotesDialog
|
||||
open={completeOpen}
|
||||
onOpenChange={setCompleteOpen}
|
||||
onConfirm={handleComplete}
|
||||
isPending={lifecycle.complete.isPending}
|
||||
title="Approve & Complete"
|
||||
description="Record why this work is approved and complete. This note is the permanent audit record and is required."
|
||||
label="Completion justification"
|
||||
placeholder="Approving and completing because..."
|
||||
minChars={20}
|
||||
confirmLabel="Approve & Complete"
|
||||
/>
|
||||
|
||||
{/* Cancel Dialog */}
|
||||
<RequiredNotesDialog
|
||||
open={cancelOpen}
|
||||
onOpenChange={setCancelOpen}
|
||||
onConfirm={handleCancel}
|
||||
isPending={lifecycle.cancel.isPending}
|
||||
title="Cancel Task"
|
||||
description="Record why this task is being cancelled. This note is the permanent audit record and is required."
|
||||
label="Cancellation reason"
|
||||
placeholder="Cancelling because..."
|
||||
minChars={10}
|
||||
confirmLabel="Cancel Task"
|
||||
destructive
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -205,6 +205,84 @@ export function CeoApproveDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// Required Notes Dialog — a generalized version of CeoApproveDialog. Collects a
|
||||
// substantive audit note (>= minChars) before confirming a decision action.
|
||||
interface RequiredNotesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (text: string) => void;
|
||||
isPending?: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
minChars: number;
|
||||
confirmLabel: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export function RequiredNotesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isPending,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
placeholder,
|
||||
minChars,
|
||||
confirmLabel,
|
||||
destructive,
|
||||
}: RequiredNotesDialogProps) {
|
||||
const [text, setText] = useState("");
|
||||
const tooShort = text.trim().length < minChars;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!tooShort) {
|
||||
onConfirm(text.trim());
|
||||
setText("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="required-notes">{label}</Label>
|
||||
<Textarea
|
||||
id="required-notes"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{text.trim().length}/{minChars} characters minimum
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
onClick={handleConfirm}
|
||||
disabled={tooShort || isPending}
|
||||
>
|
||||
{isPending ? "Working..." : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Create Branch Dialog
|
||||
interface CreateBranchDialogProps {
|
||||
open: boolean;
|
||||
|
||||
Reference in New Issue
Block a user