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:
Renn F
2026-05-24 07:10:34 +02:00
parent 5120b5ce81
commit bc5e016d6d
6 changed files with 429 additions and 51 deletions
+177 -18
View File
@@ -11,6 +11,7 @@ import {
CeoRejectDialog,
CreateBranchDialog,
CreatePRDialog,
RequiredNotesDialog,
} from "@/components/tasks/task-detail/task-action-dialogs";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
@@ -37,6 +38,12 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
const [prDialogOpen, setPrDialogOpen] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [passQaDialogOpen, setPassQaDialogOpen] = useState(false);
const [failQaDialogOpen, setFailQaDialogOpen] = useState(false);
const [docsCompleteDialogOpen, setDocsCompleteDialogOpen] = useState(false);
const [submitPmReviewDialogOpen, setSubmitPmReviewDialogOpen] = useState(false);
const [completeDialogOpen, setCompleteDialogOpen] = useState(false);
const handleAction = async (action: string) => {
if (!task) return;
@@ -76,34 +83,28 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
toast.success("Task submitted for QA");
break;
case "pass-qa":
await lifecycle.passQa.mutateAsync({ taskId: task.id });
toast.success("Task passed QA");
break;
setPassQaDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
case "fail-qa":
await lifecycle.failQa.mutateAsync({ taskId: task.id });
toast.success("Task failed QA");
break;
setFailQaDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
case "complete":
await lifecycle.complete.mutateAsync(task.id);
toast.success("Task completed");
break;
setCompleteDialogOpen(true);
return; // Don't refetch yet — dialog collects the required justification
case "cancel":
await lifecycle.cancel.mutateAsync(task.id);
toast.success("Task cancelled");
break;
setCancelDialogOpen(true);
return; // Don't refetch yet — dialog collects the required reason
case "reopen":
await lifecycle.reopen.mutateAsync(task.id);
toast.success("Task reopened");
break;
// Git workflow actions
case "docs-complete":
await lifecycle.docsComplete.mutateAsync(task.id);
toast.success("Documentation marked complete");
break;
setDocsCompleteDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
case "submit-pm-review":
await lifecycle.submitPmReview.mutateAsync(task.id);
toast.success("Submitted for PM review");
break;
setSubmitPmReviewDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
case "ceo-approve":
setApproveDialogOpen(true);
return; // Don't refetch yet — dialog collects the required note
@@ -181,6 +182,84 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
}
};
const handleCancel = async (reason: string) => {
if (!task) return;
try {
await lifecycle.cancel.mutateAsync({ taskId: task.id, reason });
toast.success("Task cancelled");
setCancelDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to cancel task");
console.error(err);
}
};
const handlePassQa = async (notes: string) => {
if (!task) return;
try {
await lifecycle.passQa.mutateAsync({ taskId: task.id, qaNotes: notes });
toast.success("Task passed QA");
setPassQaDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to pass QA");
console.error(err);
}
};
const handleFailQa = async (notes: string) => {
if (!task) return;
try {
await lifecycle.failQa.mutateAsync({ taskId: task.id, qaNotes: notes });
toast.success("Task failed QA");
setFailQaDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to fail QA");
console.error(err);
}
};
const handleDocsComplete = async (notes: string) => {
if (!task) return;
try {
await lifecycle.docsComplete.mutateAsync({ taskId: task.id, notes });
toast.success("Documentation marked complete");
setDocsCompleteDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to mark documentation complete");
console.error(err);
}
};
const handleSubmitPmReview = async (notes: string) => {
if (!task) return;
try {
await lifecycle.submitPmReview.mutateAsync({ taskId: task.id, notes });
toast.success("Submitted for PM review");
setSubmitPmReviewDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to submit for PM review");
console.error(err);
}
};
const handleComplete = async (justification: string) => {
if (!task) return;
try {
await lifecycle.complete.mutateAsync({ taskId: task.id, justification });
toast.success("Task completed");
setCompleteDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to complete task");
console.error(err);
}
};
const handleCreateBranch = async (branchType: string) => {
if (!task || !project) return;
try {
@@ -308,6 +387,86 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
isPending={lifecycle.ceoReject.isPending}
/>
<RequiredNotesDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
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
/>
<RequiredNotesDialog
open={passQaDialogOpen}
onOpenChange={setPassQaDialogOpen}
onConfirm={handlePassQa}
isPending={lifecycle.passQa.isPending}
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"
/>
<RequiredNotesDialog
open={failQaDialogOpen}
onOpenChange={setFailQaDialogOpen}
onConfirm={handleFailQa}
isPending={lifecycle.failQa.isPending}
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
/>
<RequiredNotesDialog
open={docsCompleteDialogOpen}
onOpenChange={setDocsCompleteDialogOpen}
onConfirm={handleDocsComplete}
isPending={lifecycle.docsComplete.isPending}
title="Mark Docs Complete"
description="Record what documentation was written. This note is the permanent audit record and is required."
label="Documentation notes"
placeholder="Documented the following..."
minChars={20}
confirmLabel="Mark Docs Complete"
/>
<RequiredNotesDialog
open={submitPmReviewDialogOpen}
onOpenChange={setSubmitPmReviewDialogOpen}
onConfirm={handleSubmitPmReview}
isPending={lifecycle.submitPmReview.isPending}
title="Submit for PM Review"
description="Record the summary for PM review. This note is the permanent audit record and is required."
label="Review notes"
placeholder="Submitting for PM review; summary..."
minChars={20}
confirmLabel="Submit for PM Review"
/>
<RequiredNotesDialog
open={completeDialogOpen}
onOpenChange={setCompleteDialogOpen}
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"
/>
<CreateBranchDialog
open={branchDialogOpen}
onOpenChange={setBranchDialogOpen}