feat(tests): let managers escalate and directly resolve disputed tests
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Request Discussion only ever nudged the peer lead, and the one-time
manager notification when a dispute starts had no real follow-up
action attached to it — a manager could be told a dispute existed but
had no way to actually do anything about it. Adds:
- POST /tests/{id}/escalate-to-manager — either lead can explicitly
ask managers to step in, distinct from the passive initial notice.
- POST /tests/{id}/manager-resolve-dispute — a manager decides the
final outcome (validated/rejected) directly, bypassing both leads'
votes entirely, reusing the same dual-validation event dispatch so
Jira/notifications behave like any other resolution.
Both leads are notified of the manager's final call, win or lose.
This commit is contained in:
@@ -319,6 +319,26 @@ export async function resolveDispute(testId: string, payload: ResolveDisputePayl
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Either lead on a disputed test asks a manager to step in and decide. */
|
||||
export async function escalateToManager(testId: string): Promise<{ status: string; message: string }> {
|
||||
const { data } = await client.post(`/tests/${testId}/escalate-to-manager`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ManagerResolveDisputePayload {
|
||||
outcome: "validated" | "rejected";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** A manager decides the final outcome of a disputed test directly. */
|
||||
export async function managerResolveDispute(
|
||||
testId: string,
|
||||
payload: ManagerResolveDisputePayload,
|
||||
): Promise<Test> {
|
||||
const { data } = await client.post<Test>(`/tests/${testId}/manager-resolve-dispute`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Reopen ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Reopen a rejected test — moves back to draft. */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { CheckCircle, XCircle, Loader2, X } from "lucide-react";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────
|
||||
|
||||
interface ManagerDecisionModalProps {
|
||||
outcome: "validated" | "rejected";
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (notes: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────
|
||||
|
||||
export default function ManagerDecisionModal({
|
||||
outcome,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ManagerDecisionModalProps) {
|
||||
const [notes, setNotes] = useState("");
|
||||
const isValidating = outcome === "validated";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{isValidating ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{isValidating ? "Validate This Test" : "Reject This Test"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<p className="text-sm text-gray-400">
|
||||
The two leads couldn't agree — you're making the final call as manager.
|
||||
This ends the dispute immediately, without either lead having to change their vote.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className={`w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-1 ${
|
||||
isValidating
|
||||
? "focus:border-green-500 focus:ring-green-500"
|
||||
: "focus:border-red-500 focus:ring-red-500"
|
||||
}`}
|
||||
placeholder="Reasoning for your decision..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 border-t border-gray-800 px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(notes)}
|
||||
disabled={isSubmitting}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 ${
|
||||
isValidating ? "bg-green-600 hover:bg-green-500" : "bg-red-600 hover:bg-red-500"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isValidating ? "Confirm Validation" : "Confirm Rejection"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { requestDiscussion } from "../../api/tests";
|
||||
import { requestDiscussion, escalateToManager } from "../../api/tests";
|
||||
import type { Test, TestState, User, DataClassification } from "../../types/models";
|
||||
import type { OperatorOut } from "../../api/users";
|
||||
import LiveTimer from "./LiveTimer";
|
||||
@@ -83,6 +83,7 @@ interface TestDetailHeaderProps {
|
||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||
onOpenReviewModal: (side: "red" | "blue") => void;
|
||||
onOpenResolveDisputeModal: () => void;
|
||||
onOpenManagerDecisionModal: (outcome: "validated" | "rejected") => void;
|
||||
onReopen: () => void;
|
||||
onPauseTimer: () => void;
|
||||
onResumeTimer: () => void;
|
||||
@@ -113,6 +114,7 @@ export default function TestDetailHeader({
|
||||
onOpenValidateModal,
|
||||
onOpenReviewModal,
|
||||
onOpenResolveDisputeModal,
|
||||
onOpenManagerDecisionModal,
|
||||
onReopen,
|
||||
onPauseTimer,
|
||||
onResumeTimer,
|
||||
@@ -146,6 +148,12 @@ export default function TestDetailHeader({
|
||||
},
|
||||
});
|
||||
|
||||
const [escalated, setEscalated] = useState(false);
|
||||
const escalateMutation = useMutation({
|
||||
mutationFn: () => escalateToManager(test.id),
|
||||
onSuccess: () => setEscalated(true),
|
||||
});
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return null;
|
||||
return new Date(d).toLocaleDateString("en-US", {
|
||||
@@ -403,6 +411,58 @@ export default function TestDetailHeader({
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
// Either lead can escalate — peer discussion isn't always enough.
|
||||
if (role === "red_lead" || role === "blue_lead") {
|
||||
buttons.push(
|
||||
escalated ? (
|
||||
<span
|
||||
key="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-500 cursor-not-allowed"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Manager Notified ✓
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key="escalate"
|
||||
onClick={() => escalateMutation.mutate()}
|
||||
disabled={escalateMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-purple-500/40 bg-purple-500/10 px-4 py-2 text-sm font-medium text-purple-400 hover:bg-purple-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{escalateMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCheck className="h-4 w-4" />}
|
||||
Escalate to Manager
|
||||
</button>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Manager makes the final call — bypasses both leads entirely.
|
||||
if (role === "manager") {
|
||||
buttons.push(
|
||||
<div key="manager-decision" className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onOpenManagerDecisionModal("validated")}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-green-700 px-4 py-2 text-sm font-medium text-white hover:bg-green-600 transition-colors"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Validate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenManagerDecisionModal("rejected")}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-700/80 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
Your decision is final — bypasses both leads' votes
|
||||
</p>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Leads on rejected -> Reopen
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
reviewAsRedLead,
|
||||
reviewAsBlueLead,
|
||||
resolveDispute,
|
||||
managerResolveDispute,
|
||||
reopenTest,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
@@ -37,6 +38,7 @@ import TeamTabs from "../components/test-detail/TeamTabs";
|
||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||
import ReviewModal from "../components/test-detail/ReviewModal";
|
||||
import ResolveDisputeModal from "../components/test-detail/ResolveDisputeModal";
|
||||
import ManagerDecisionModal from "../components/test-detail/ManagerDecisionModal";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||
@@ -90,6 +92,11 @@ export default function TestDetailPage() {
|
||||
|
||||
const [resolveDisputeModalOpen, setResolveDisputeModalOpen] = useState(false);
|
||||
|
||||
const [managerDecisionModal, setManagerDecisionModal] = useState<{
|
||||
open: boolean;
|
||||
outcome: "validated" | "rejected";
|
||||
}>({ open: false, outcome: "validated" });
|
||||
|
||||
const [confirmReopen, setConfirmReopen] = useState(false);
|
||||
const [holdModal, setHoldModal] = useState(false);
|
||||
const [holdReason, setHoldReason] = useState("");
|
||||
@@ -346,6 +353,17 @@ export default function TestDetailPage() {
|
||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||
});
|
||||
|
||||
const managerResolveDisputeMutation = useMutation({
|
||||
mutationFn: (notes: string) =>
|
||||
managerResolveDispute(testId!, { outcome: managerDecisionModal.outcome, notes: notes || undefined }),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
setManagerDecisionModal({ open: false, outcome: "validated" });
|
||||
showToast(`Dispute resolved as ${managerDecisionModal.outcome}`, "success");
|
||||
},
|
||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||
});
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: () => reopenTest(testId!),
|
||||
onSuccess: () => {
|
||||
@@ -585,6 +603,7 @@ export default function TestDetailPage() {
|
||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
||||
onOpenResolveDisputeModal={() => setResolveDisputeModalOpen(true)}
|
||||
onOpenManagerDecisionModal={(outcome) => setManagerDecisionModal({ open: true, outcome })}
|
||||
onReopen={() => setConfirmReopen(true)}
|
||||
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||
onResumeTimer={() => resumeTimerMutation.mutate()}
|
||||
@@ -894,6 +913,16 @@ export default function TestDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Manager Decision Modal (manager makes the final call on a dispute) */}
|
||||
{managerDecisionModal.open && (
|
||||
<ManagerDecisionModal
|
||||
outcome={managerDecisionModal.outcome}
|
||||
isSubmitting={managerResolveDisputeMutation.isPending}
|
||||
onSubmit={(notes) => managerResolveDisputeMutation.mutate(notes)}
|
||||
onClose={() => setManagerDecisionModal({ open: false, outcome: "validated" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save as Template Modal */}
|
||||
{showTemplateModal && (
|
||||
<SaveAsTemplateModal
|
||||
|
||||
Reference in New Issue
Block a user