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

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:
kitos
2026-07-15 13:03:01 +02:00
parent 32f4fd25bd
commit 3a01facd46
9 changed files with 444 additions and 1 deletions
+20
View File
@@ -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
+29
View File
@@ -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