feat(tests): dispute resolution UI — manager escalation + red/blue queue selector
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

This commit is contained in:
kitos
2026-07-06 12:40:21 +02:00
parent f53e124c50
commit 19c4866103
4 changed files with 158 additions and 4 deletions
+13
View File
@@ -248,6 +248,19 @@ export async function validateAsBlueLead(
return data;
}
// ── Dispute resolution ───────────────────────────────────────────────
export interface ResolveDisputePayload {
target_team: "red" | "blue";
notes?: string;
}
/** The approving lead flips to reject, choosing which team must redo the work. */
export async function resolveDispute(testId: string, payload: ResolveDisputePayload): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/resolve-dispute`, payload);
return data;
}
// ── Reopen ─────────────────────────────────────────────────────────
/** Reopen a rejected test — moves back to draft. */
@@ -0,0 +1,112 @@
import { useState } from "react";
import { Shield, ShieldCheck, Loader2, X, XCircle } from "lucide-react";
// ── Props ──────────────────────────────────────────────────────────
interface ResolveDisputeModalProps {
isSubmitting: boolean;
onSubmit: (targetTeam: "red" | "blue", notes: string) => void;
onClose: () => void;
}
// ── Component ──────────────────────────────────────────────────────
export default function ResolveDisputeModal({
isSubmitting,
onSubmit,
onClose,
}: ResolveDisputeModalProps) {
const [targetTeam, setTargetTeam] = useState<"red" | "blue" | null>(null);
const [notes, setNotes] = useState("");
const canSubmit = targetTeam !== null && !isSubmitting;
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">
<XCircle className="h-5 w-5 text-red-400" />
<h3 className="text-lg font-semibold text-white">Change Vote to Rejected</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">
You're agreeing that this test needs rework. Instead of restarting from scratch,
pick which team's work is actually the problem the test goes straight back to
their queue and the other team's work is left untouched.
</p>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-300">Send back to</h4>
<div className="flex gap-3">
<button
onClick={() => setTargetTeam("red")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
targetTeam === "red"
? "border-orange-500 bg-orange-500/10 text-orange-400"
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
}`}
>
<Shield className="h-4 w-4" />
Red Team Queue
</button>
<button
onClick={() => setTargetTeam("blue")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
targetTeam === "blue"
? "border-indigo-500 bg-indigo-500/10 text-indigo-400"
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
}`}
>
<ShieldCheck className="h-4 w-4" />
Blue Team Queue
</button>
</div>
</div>
<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:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500"
placeholder="What needs to be fixed..."
/>
</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={() => targetTeam && onSubmit(targetTeam, notes)}
disabled={!canSubmit}
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Confirm Rejection
</button>
</div>
</div>
</div>
);
}
@@ -79,6 +79,7 @@ interface TestDetailHeaderProps {
onStartBlueWork: () => void;
onOpenValidateModal: (side: "red" | "blue") => void;
onOpenReviewModal: (side: "red" | "blue") => void;
onOpenResolveDisputeModal: () => void;
onReopen: () => void;
onPauseTimer: () => void;
onResumeTimer: () => void;
@@ -100,6 +101,7 @@ export default function TestDetailHeader({
onStartBlueWork,
onOpenValidateModal,
onOpenReviewModal,
onOpenResolveDisputeModal,
onReopen,
onPauseTimer,
onResumeTimer,
@@ -329,8 +331,6 @@ export default function TestDetailHeader({
(role === "blue_lead" && test.blue_validation_status === "rejected") ||
(role === "admin" && test.blue_validation_status === "rejected");
const approvingSide: "red" | "blue" =
test.red_validation_status === "approved" ? "red" : "blue";
const rejectingSide: "red" | "blue" =
test.red_validation_status === "rejected" ? "red" : "blue";
@@ -353,9 +353,9 @@ export default function TestDetailHeader({
Request Discussion
</button>
)}
{/* Change approving vote to rejected */}
{/* Change approving vote to rejected — routes to the team at fault */}
<button
onClick={() => onOpenValidateModal(approvingSide)}
onClick={onOpenResolveDisputeModal}
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" />
+29
View File
@@ -16,6 +16,7 @@ import {
validateAsBlueLead,
reviewAsRedLead,
reviewAsBlueLead,
resolveDispute,
reopenTest,
pauseTimer,
resumeTimer,
@@ -32,6 +33,7 @@ import TestDetailHeader from "../components/test-detail/TestDetailHeader";
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 ConfirmDialog from "../components/ConfirmDialog";
import JiraLinkPanel from "../components/JiraLinkPanel";
import TestPhaseTimeline from "../components/TestPhaseTimeline";
@@ -64,6 +66,8 @@ export default function TestDetailPage() {
side: "red" | "blue";
}>({ open: false, side: "red" });
const [resolveDisputeModalOpen, setResolveDisputeModalOpen] = useState(false);
const [confirmReopen, setConfirmReopen] = useState(false);
const [holdModal, setHoldModal] = useState(false);
const [holdReason, setHoldReason] = useState("");
@@ -284,6 +288,17 @@ export default function TestDetailPage() {
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const resolveDisputeMutation = useMutation({
mutationFn: (payload: { target_team: "red" | "blue"; notes?: string }) =>
resolveDispute(testId!, payload),
onSuccess: () => {
invalidateAll();
setResolveDisputeModalOpen(false);
showToast("Dispute resolved — test sent back for rework", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
const reopenMutation = useMutation({
mutationFn: () => reopenTest(testId!),
onSuccess: () => {
@@ -406,6 +421,10 @@ export default function TestDetailPage() {
});
};
const handleResolveDisputeSubmit = (targetTeam: "red" | "blue", notes: string) => {
resolveDisputeMutation.mutate({ target_team: targetTeam, notes: notes || undefined });
};
const isTransitioning =
startExecMutation.isPending ||
submitRedMutation.isPending ||
@@ -486,6 +505,7 @@ export default function TestDetailPage() {
onStartBlueWork={() => startBlueWorkMutation.mutate()}
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
onOpenResolveDisputeModal={() => setResolveDisputeModalOpen(true)}
onReopen={() => setConfirmReopen(true)}
onPauseTimer={() => pauseTimerMutation.mutate()}
onResumeTimer={() => resumeTimerMutation.mutate()}
@@ -780,6 +800,15 @@ export default function TestDetailPage() {
/>
)}
{/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */}
{resolveDisputeModalOpen && (
<ResolveDisputeModal
isSubmitting={resolveDisputeMutation.isPending}
onSubmit={handleResolveDisputeSubmit}
onClose={() => setResolveDisputeModalOpen(false)}
/>
)}
{/* Save as Template Modal */}
{showTemplateModal && (
<SaveAsTemplateModal