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:
@@ -11,6 +11,7 @@ import {
|
|||||||
CeoRejectDialog,
|
CeoRejectDialog,
|
||||||
CreateBranchDialog,
|
CreateBranchDialog,
|
||||||
CreatePRDialog,
|
CreatePRDialog,
|
||||||
|
RequiredNotesDialog,
|
||||||
} from "@/components/tasks/task-detail/task-action-dialogs";
|
} from "@/components/tasks/task-detail/task-action-dialogs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -37,6 +38,12 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
|||||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||||
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
|
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
|
||||||
const [prDialogOpen, setPrDialogOpen] = 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) => {
|
const handleAction = async (action: string) => {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
@@ -76,34 +83,28 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
|||||||
toast.success("Task submitted for QA");
|
toast.success("Task submitted for QA");
|
||||||
break;
|
break;
|
||||||
case "pass-qa":
|
case "pass-qa":
|
||||||
await lifecycle.passQa.mutateAsync({ taskId: task.id });
|
setPassQaDialogOpen(true);
|
||||||
toast.success("Task passed QA");
|
return; // Don't refetch yet — dialog collects the required note
|
||||||
break;
|
|
||||||
case "fail-qa":
|
case "fail-qa":
|
||||||
await lifecycle.failQa.mutateAsync({ taskId: task.id });
|
setFailQaDialogOpen(true);
|
||||||
toast.success("Task failed QA");
|
return; // Don't refetch yet — dialog collects the required note
|
||||||
break;
|
|
||||||
case "complete":
|
case "complete":
|
||||||
await lifecycle.complete.mutateAsync(task.id);
|
setCompleteDialogOpen(true);
|
||||||
toast.success("Task completed");
|
return; // Don't refetch yet — dialog collects the required justification
|
||||||
break;
|
|
||||||
case "cancel":
|
case "cancel":
|
||||||
await lifecycle.cancel.mutateAsync(task.id);
|
setCancelDialogOpen(true);
|
||||||
toast.success("Task cancelled");
|
return; // Don't refetch yet — dialog collects the required reason
|
||||||
break;
|
|
||||||
case "reopen":
|
case "reopen":
|
||||||
await lifecycle.reopen.mutateAsync(task.id);
|
await lifecycle.reopen.mutateAsync(task.id);
|
||||||
toast.success("Task reopened");
|
toast.success("Task reopened");
|
||||||
break;
|
break;
|
||||||
// Git workflow actions
|
// Git workflow actions
|
||||||
case "docs-complete":
|
case "docs-complete":
|
||||||
await lifecycle.docsComplete.mutateAsync(task.id);
|
setDocsCompleteDialogOpen(true);
|
||||||
toast.success("Documentation marked complete");
|
return; // Don't refetch yet — dialog collects the required note
|
||||||
break;
|
|
||||||
case "submit-pm-review":
|
case "submit-pm-review":
|
||||||
await lifecycle.submitPmReview.mutateAsync(task.id);
|
setSubmitPmReviewDialogOpen(true);
|
||||||
toast.success("Submitted for PM review");
|
return; // Don't refetch yet — dialog collects the required note
|
||||||
break;
|
|
||||||
case "ceo-approve":
|
case "ceo-approve":
|
||||||
setApproveDialogOpen(true);
|
setApproveDialogOpen(true);
|
||||||
return; // Don't refetch yet — dialog collects the required note
|
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) => {
|
const handleCreateBranch = async (branchType: string) => {
|
||||||
if (!task || !project) return;
|
if (!task || !project) return;
|
||||||
try {
|
try {
|
||||||
@@ -308,6 +387,86 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
|||||||
isPending={lifecycle.ceoReject.isPending}
|
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
|
<CreateBranchDialog
|
||||||
open={branchDialogOpen}
|
open={branchDialogOpen}
|
||||||
onOpenChange={setBranchDialogOpen}
|
onOpenChange={setBranchDialogOpen}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ import {
|
|||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { KanbanCard } from "./kanban-card";
|
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 {
|
interface ColumnConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,6 +63,7 @@ export function KanbanBoard({
|
|||||||
const lifecycle = useTaskLifecycle();
|
const lifecycle = useTaskLifecycle();
|
||||||
const updateTask = useUpdateTask();
|
const updateTask = useUpdateTask();
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||||
|
const [pendingNotesAction, setPendingNotesAction] = useState<PendingNotesAction | null>(null);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -138,23 +147,19 @@ export function KanbanBoard({
|
|||||||
toast.success("Task unblocked");
|
toast.success("Task unblocked");
|
||||||
break;
|
break;
|
||||||
case TaskStatus.AWAITING_QA:
|
case TaskStatus.AWAITING_QA:
|
||||||
await lifecycle.passQa.mutateAsync({ taskId });
|
setPendingNotesAction({ kind: "pass-qa", taskId });
|
||||||
toast.success("QA passed");
|
return; // Dialog collects the required note
|
||||||
break;
|
|
||||||
case TaskStatus.AWAITING_DOCUMENTATION:
|
case TaskStatus.AWAITING_DOCUMENTATION:
|
||||||
await lifecycle.complete.mutateAsync(taskId);
|
setPendingNotesAction({ kind: "complete", taskId });
|
||||||
toast.success("Task completed");
|
return; // Dialog collects the required justification
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "pass-qa":
|
case "pass-qa":
|
||||||
await lifecycle.passQa.mutateAsync({ taskId });
|
setPendingNotesAction({ kind: "pass-qa", taskId });
|
||||||
toast.success("QA passed");
|
return; // Dialog collects the required note
|
||||||
break;
|
|
||||||
case "fail-qa":
|
case "fail-qa":
|
||||||
await lifecycle.failQa.mutateAsync({ taskId });
|
setPendingNotesAction({ kind: "fail-qa", taskId });
|
||||||
toast.success("QA failed - returned to developer");
|
return; // Dialog collects the required note
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
refetch();
|
refetch();
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -225,6 +301,17 @@ export function KanbanBoard({
|
|||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
|
{pendingNotesAction && (
|
||||||
|
<RequiredNotesDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingNotesAction(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleNotesConfirm}
|
||||||
|
{...notesDialogConfig[pendingNotesAction.kind]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { MoreHorizontal, Play, Pause, CheckCircle, XCircle, Pencil, Trash2, Clock, MessageSquare } from "lucide-react";
|
import { MoreHorizontal, Play, Pause, CheckCircle, XCircle, Pencil, Trash2, Clock, MessageSquare } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { EditTaskDialog } from "./edit-task-dialog";
|
import { EditTaskDialog } from "./edit-task-dialog";
|
||||||
|
import { RequiredNotesDialog } from "./task-detail/task-action-dialogs";
|
||||||
|
|
||||||
interface TaskActionsProps {
|
interface TaskActionsProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -44,6 +45,8 @@ export function TaskActions({
|
|||||||
const deleteTask = useDeleteTask();
|
const deleteTask = useDeleteTask();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [completeOpen, setCompleteOpen] = useState(false);
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
|
||||||
const handleAction = async (action: string) => {
|
const handleAction = async (action: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -65,12 +68,10 @@ export function TaskActions({
|
|||||||
toast.success("Task resumed");
|
toast.success("Task resumed");
|
||||||
break;
|
break;
|
||||||
case "complete":
|
case "complete":
|
||||||
await lifecycle.complete.mutateAsync(task.id);
|
setCompleteOpen(true);
|
||||||
toast.success("Task completed");
|
|
||||||
break;
|
break;
|
||||||
case "cancel":
|
case "cancel":
|
||||||
await lifecycle.cancel.mutateAsync(task.id);
|
setCancelOpen(true);
|
||||||
toast.success("Task cancelled");
|
|
||||||
break;
|
break;
|
||||||
case "reopen":
|
case "reopen":
|
||||||
await lifecycle.reopen.mutateAsync(task.id);
|
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 () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTask.mutateAsync(task.id);
|
await deleteTask.mutateAsync(task.id);
|
||||||
@@ -229,6 +250,35 @@ export function TaskActions({
|
|||||||
onOpenChange={setEditOpen}
|
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 */}
|
{/* Delete Confirmation */}
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<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
|
// Create Branch Dialog
|
||||||
interface CreateBranchDialogProps {
|
interface CreateBranchDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -158,24 +158,26 @@ export function useTaskLifecycle() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const passQa = useMutation({
|
const passQa = useMutation({
|
||||||
mutationFn: ({ taskId, qaNotes }: { taskId: string; qaNotes?: string }) =>
|
mutationFn: ({ taskId, qaNotes }: { taskId: string; qaNotes: string }) =>
|
||||||
tasksApi.passQa(taskId, qaNotes),
|
tasksApi.passQa(taskId, qaNotes),
|
||||||
onSuccess: invalidateTask,
|
onSuccess: invalidateTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
const failQa = useMutation({
|
const failQa = useMutation({
|
||||||
mutationFn: ({ taskId, qaNotes }: { taskId: string; qaNotes?: string }) =>
|
mutationFn: ({ taskId, qaNotes }: { taskId: string; qaNotes: string }) =>
|
||||||
tasksApi.failQa(taskId, qaNotes),
|
tasksApi.failQa(taskId, qaNotes),
|
||||||
onSuccess: invalidateTask,
|
onSuccess: invalidateTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
const complete = useMutation({
|
const complete = useMutation({
|
||||||
mutationFn: (taskId: string) => tasksApi.complete(taskId),
|
mutationFn: ({ taskId, justification }: { taskId: string; justification: string }) =>
|
||||||
|
tasksApi.complete(taskId, justification),
|
||||||
onSuccess: invalidateTask,
|
onSuccess: invalidateTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancel = useMutation({
|
const cancel = useMutation({
|
||||||
mutationFn: (taskId: string) => tasksApi.cancel(taskId),
|
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
|
||||||
|
tasksApi.cancel(taskId, reason),
|
||||||
onSuccess: invalidateTask,
|
onSuccess: invalidateTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,12 +192,14 @@ export function useTaskLifecycle() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const docsComplete = useMutation({
|
const docsComplete = useMutation({
|
||||||
mutationFn: (taskId: string) => tasksApi.docsComplete(taskId),
|
mutationFn: ({ taskId, notes }: { taskId: string; notes: string }) =>
|
||||||
|
tasksApi.docsComplete(taskId, notes),
|
||||||
onSuccess: invalidateTask,
|
onSuccess: invalidateTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitPmReview = useMutation({
|
const submitPmReview = useMutation({
|
||||||
mutationFn: (taskId: string) => tasksApi.submitPmReview(taskId),
|
mutationFn: ({ taskId, notes }: { taskId: string; notes: string }) =>
|
||||||
|
tasksApi.submitPmReview(taskId, notes),
|
||||||
onSuccess: invalidateTask,
|
onSuccess: invalidateTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -242,7 +242,7 @@ export const tasksApi = {
|
|||||||
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.AWAITING_DOCUMENTATION, qa_verified: true, qa_notes: qaNotes ?? null, updated_at: now };
|
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.AWAITING_DOCUMENTATION, qa_verified: true, qa_notes: qaNotes ?? null, updated_at: now };
|
||||||
return mockTasks[idx];
|
return mockTasks[idx];
|
||||||
}
|
}
|
||||||
const { data } = await api.post<Task>("/tasks/" + taskId + "/pass-qa", { qa_notes: qaNotes });
|
const { data } = await api.post<Task>("/tasks/" + taskId + "/pass-qa", { notes: qaNotes });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -254,11 +254,11 @@ export const tasksApi = {
|
|||||||
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.NEEDS_REVISION, qa_verified: false, qa_notes: qaNotes ?? null, updated_at: now };
|
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.NEEDS_REVISION, qa_verified: false, qa_notes: qaNotes ?? null, updated_at: now };
|
||||||
return mockTasks[idx];
|
return mockTasks[idx];
|
||||||
}
|
}
|
||||||
const { data } = await api.post<Task>("/tasks/" + taskId + "/fail-qa", { qa_notes: qaNotes });
|
const { data } = await api.post<Task>("/tasks/" + taskId + "/fail-qa", { notes: qaNotes });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
complete: async (taskId: string): Promise<Task> => {
|
complete: async (taskId: string, justification: string): Promise<Task> => {
|
||||||
if (isMockMode()) {
|
if (isMockMode()) {
|
||||||
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
||||||
if (idx === -1) throw new Error("Task not found");
|
if (idx === -1) throw new Error("Task not found");
|
||||||
@@ -266,11 +266,11 @@ export const tasksApi = {
|
|||||||
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.COMPLETED, completed_at: now, updated_at: now };
|
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.COMPLETED, completed_at: now, updated_at: now };
|
||||||
return mockTasks[idx];
|
return mockTasks[idx];
|
||||||
}
|
}
|
||||||
const { data } = await api.post<Task>("/tasks/" + taskId + "/complete");
|
const { data } = await api.post<Task>("/tasks/" + taskId + "/complete", { justification });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel: async (taskId: string): Promise<Task> => {
|
cancel: async (taskId: string, reason: string): Promise<Task> => {
|
||||||
if (isMockMode()) {
|
if (isMockMode()) {
|
||||||
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
||||||
if (idx === -1) throw new Error("Task not found");
|
if (idx === -1) throw new Error("Task not found");
|
||||||
@@ -278,7 +278,7 @@ export const tasksApi = {
|
|||||||
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.CANCELLED, updated_at: now };
|
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.CANCELLED, updated_at: now };
|
||||||
return mockTasks[idx];
|
return mockTasks[idx];
|
||||||
}
|
}
|
||||||
const { data } = await api.post<Task>("/tasks/" + taskId + "/cancel");
|
const { data } = await api.post<Task>("/tasks/" + taskId + "/cancel", { reason });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ export const tasksApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Mark documentation as complete (Documenter only)
|
// Mark documentation as complete (Documenter only)
|
||||||
docsComplete: async (taskId: string, docNotes?: string): Promise<Task> => {
|
docsComplete: async (taskId: string, docNotes: string): Promise<Task> => {
|
||||||
if (isMockMode()) {
|
if (isMockMode()) {
|
||||||
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
||||||
if (idx === -1) throw new Error("Task not found");
|
if (idx === -1) throw new Error("Task not found");
|
||||||
@@ -318,7 +318,7 @@ export const tasksApi = {
|
|||||||
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.AWAITING_PM_REVIEW, updated_at: now };
|
mockTasks[idx] = { ...mockTasks[idx], status: TaskStatus.AWAITING_PM_REVIEW, updated_at: now };
|
||||||
return mockTasks[idx];
|
return mockTasks[idx];
|
||||||
}
|
}
|
||||||
const { data } = await api.post<Task>("/tasks/" + taskId + "/docs-complete", docNotes ?? null);
|
const { data } = await api.post<Task>("/tasks/" + taskId + "/docs-complete", { notes: docNotes });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -517,7 +517,7 @@ export const tasksApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
submitPmReview: async (taskId: string): Promise<Task> => {
|
submitPmReview: async (taskId: string, notes: string): Promise<Task> => {
|
||||||
if (isMockMode()) {
|
if (isMockMode()) {
|
||||||
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
const idx = mockTasks.findIndex((t) => t.id === taskId);
|
||||||
if (idx === -1) throw new Error("Task not found");
|
if (idx === -1) throw new Error("Task not found");
|
||||||
@@ -529,7 +529,7 @@ export const tasksApi = {
|
|||||||
};
|
};
|
||||||
return mockTasks[idx];
|
return mockTasks[idx];
|
||||||
}
|
}
|
||||||
const { data } = await api.post<Task>("/tasks/" + taskId + "/submit-pm-review");
|
const { data } = await api.post<Task>("/tasks/" + taskId + "/submit-pm-review", { notes });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ export const tasksApi = {
|
|||||||
message: "Task escalated to CEO (mock)",
|
message: "Task escalated to CEO (mock)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { data } = await api.post<EscalateResponse>("/tasks/" + taskId + "/escalate-to-ceo", { reason });
|
const { data } = await api.post<EscalateResponse>("/tasks/" + taskId + "/escalate-to-ceo", { notes: reason });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user