6d6f87b968
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
- RT/BT Start/End Date are genuine Jira "datetime" custom fields (confirmed via issue_editmeta), not plain dates. Pushing a bare "YYYY-MM-DD" string made Jira default the time-of-day to midnight, so RT Start Date and RT End Date ended up showing the same meaningless midnight timestamp instead of the actual execution window. Now sends a full ISO datetime. - The test detail page showed "RT: Unassigned" for the operator who WAS correctly assigned, because GET /users/operators (the only source AssigneeControl used to resolve an assignee ID into a username) is restricted to leads/managers — a plain operator has no other way to resolve their own ID. TestOut now resolves and includes the assignee's username directly (red/blue tech + reviewer), so the badge no longer depends on a permission-gated list the viewer might not have access to.
839 lines
33 KiB
TypeScript
839 lines
33 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
FlaskConical,
|
|
Play,
|
|
Send,
|
|
CheckCircle,
|
|
XCircle,
|
|
RotateCcw,
|
|
Loader2,
|
|
Shield,
|
|
ShieldCheck,
|
|
AlertTriangle,
|
|
MessageSquare,
|
|
X,
|
|
UserCheck,
|
|
PauseCircle,
|
|
PlayCircle,
|
|
} from "lucide-react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { requestDiscussion } from "../../api/tests";
|
|
import type { Test, TestState, User, DataClassification } from "../../types/models";
|
|
import type { OperatorOut } from "../../api/users";
|
|
import LiveTimer from "./LiveTimer";
|
|
import DataClassificationBadge from "./DataClassificationBadge";
|
|
import AssigneeControl from "./AssigneeControl";
|
|
|
|
// ── Progress steps ─────────────────────────────────────────────────
|
|
|
|
const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
|
{ key: "draft", label: "Draft" },
|
|
{ key: "red_executing", label: "Red Exec" },
|
|
{ key: "red_review", label: "Red Review" },
|
|
{ key: "blue_evaluating", label: "Blue Eval" },
|
|
{ key: "blue_review", label: "Blue Review" },
|
|
{ key: "in_review", label: "Review" },
|
|
{ key: "validated", label: "Validated" },
|
|
];
|
|
|
|
const STATE_INDEX: Record<TestState, number> = {
|
|
draft: 0,
|
|
red_executing: 1,
|
|
red_review: 2,
|
|
blue_evaluating: 3,
|
|
blue_review: 4,
|
|
in_review: 5,
|
|
validated: 6,
|
|
rejected: -1,
|
|
disputed: 5, // same step as in_review (still in validation phase)
|
|
};
|
|
|
|
// ── Badge colors ───────────────────────────────────────────────────
|
|
|
|
const STATE_BADGE: Record<TestState, string> = {
|
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
|
disputed: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
|
};
|
|
|
|
/** "Queued Blue Team" is a display-only label for blue_evaluating + unassigned. */
|
|
function getStateLabel(test: Test): string {
|
|
if (test.state === "blue_evaluating" && !test.blue_work_started_at) {
|
|
return "Queued Blue Team";
|
|
}
|
|
return test.state.replace(/_/g, " ");
|
|
}
|
|
|
|
// ── Props ──────────────────────────────────────────────────────────
|
|
|
|
interface TestDetailHeaderProps {
|
|
test: Test;
|
|
user: User | null;
|
|
isTransitioning: boolean;
|
|
onStartExecution: () => void;
|
|
onSubmitRed: () => void;
|
|
onSubmitBlue: () => void;
|
|
onStartBlueWork: () => void;
|
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
|
onOpenReviewModal: (side: "red" | "blue") => void;
|
|
onOpenResolveDisputeModal: () => void;
|
|
onReopen: () => void;
|
|
onPauseTimer: () => void;
|
|
onResumeTimer: () => void;
|
|
isTogglingTimer: boolean;
|
|
onHold: () => void;
|
|
onResume: () => void;
|
|
isTogglingHold: boolean;
|
|
onUpdateClassification: (value: DataClassification) => void;
|
|
isUpdatingClassification: boolean;
|
|
operators: OperatorOut[];
|
|
onAssignOperator: (
|
|
field: "red_tech_assignee" | "blue_tech_assignee" | "red_reviewer_assignee" | "blue_reviewer_assignee",
|
|
userId: string | null,
|
|
) => void;
|
|
isAssigningOperator: boolean;
|
|
}
|
|
|
|
// ── Component ──────────────────────────────────────────────────────
|
|
|
|
export default function TestDetailHeader({
|
|
test,
|
|
user,
|
|
isTransitioning,
|
|
onStartExecution,
|
|
onSubmitRed,
|
|
onSubmitBlue,
|
|
onStartBlueWork,
|
|
onOpenValidateModal,
|
|
onOpenReviewModal,
|
|
onOpenResolveDisputeModal,
|
|
onReopen,
|
|
onPauseTimer,
|
|
onResumeTimer,
|
|
isTogglingTimer,
|
|
onHold,
|
|
onResume,
|
|
isTogglingHold,
|
|
onUpdateClassification,
|
|
isUpdatingClassification,
|
|
operators,
|
|
onAssignOperator,
|
|
isAssigningOperator,
|
|
}: TestDetailHeaderProps) {
|
|
const role = user?.role ?? "";
|
|
const currentIdx = STATE_INDEX[test.state];
|
|
const [showDiscussModal, setShowDiscussModal] = useState(false);
|
|
const [discussionSent, setDiscussionSent] = useState(false);
|
|
const [discussResult, setDiscussResult] = useState<{
|
|
username: string; email: string | null; role: string;
|
|
} | null>(null);
|
|
|
|
const discussMutation = useMutation({
|
|
mutationFn: () => requestDiscussion(test.id),
|
|
onSuccess: (data) => {
|
|
setDiscussResult({
|
|
username: data.rejector_username,
|
|
email: data.rejector_email,
|
|
role: data.rejector_role,
|
|
});
|
|
setDiscussionSent(true);
|
|
},
|
|
});
|
|
|
|
const formatDate = (d: string | null) => {
|
|
if (!d) return null;
|
|
return new Date(d).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
};
|
|
|
|
// ── Contextual action buttons ────────────────────────────────────
|
|
|
|
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"];
|
|
const canHold =
|
|
HOLDABLE_STATES.includes(test.state) &&
|
|
(role === "red_tech" || role === "blue_tech");
|
|
|
|
const renderActions = () => {
|
|
const buttons: React.ReactNode[] = [];
|
|
|
|
// On Hold banner + Resume button (shown first when test is on hold)
|
|
if (test.is_on_hold && canHold) {
|
|
buttons.push(
|
|
<button
|
|
key="resume"
|
|
onClick={onResume}
|
|
disabled={isTogglingHold}
|
|
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 disabled:opacity-50"
|
|
>
|
|
{isTogglingHold ? <Loader2 className="h-4 w-4 animate-spin" /> : <PlayCircle className="h-4 w-4" />}
|
|
Resume Test
|
|
</button>,
|
|
);
|
|
return <div className="flex flex-wrap items-center gap-2">{buttons}</div>;
|
|
}
|
|
|
|
// Red Team in draft -> Start Execution
|
|
if (
|
|
test.state === "draft" &&
|
|
(role === "red_tech" || role === "red_lead")
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="start"
|
|
onClick={onStartExecution}
|
|
disabled={isTransitioning}
|
|
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
|
Start Execution
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Red Team in red_executing -> Submit to Blue Team (requires ≥1 red evidence)
|
|
if (
|
|
test.state === "red_executing" &&
|
|
(role === "red_tech" || role === "red_lead")
|
|
) {
|
|
const hasRedEvidence = (test.red_evidences?.length ?? 0) > 0;
|
|
buttons.push(
|
|
<div key="submit-red" className="flex flex-col items-end gap-1">
|
|
<button
|
|
onClick={onSubmitRed}
|
|
disabled={isTransitioning || !hasRedEvidence}
|
|
title={!hasRedEvidence ? "Upload at least one Red Team evidence file before submitting" : undefined}
|
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
Submit to Blue Team
|
|
</button>
|
|
{!hasRedEvidence && (
|
|
<span className="text-[10px] text-orange-400">⚠ Upload evidence first</span>
|
|
)}
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
// Red Lead assigned to review this submission -> Review Red Submission
|
|
if (
|
|
test.state === "red_review" &&
|
|
role === "red_lead" && test.red_reviewer_assignee === user?.id
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="review-red"
|
|
onClick={() => onOpenReviewModal("red")}
|
|
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 transition-colors"
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
Review Red Submission
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Blue Lead assigned to review this submission -> Review Blue Submission
|
|
if (
|
|
test.state === "blue_review" &&
|
|
role === "blue_lead" && test.blue_reviewer_assignee === user?.id
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="review-blue"
|
|
onClick={() => onOpenReviewModal("blue")}
|
|
className="flex items-center gap-1.5 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-500 transition-colors"
|
|
>
|
|
<ShieldCheck className="h-4 w-4" />
|
|
Review Blue Submission
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Blue Team in blue_evaluating:
|
|
// - if not picked up yet: show "Start Evaluation" button
|
|
// - if already picked up: show "Submit for Review" button
|
|
if (
|
|
test.state === "blue_evaluating" &&
|
|
(role === "blue_tech" || role === "blue_lead")
|
|
) {
|
|
if (!test.blue_work_started_at) {
|
|
buttons.push(
|
|
<button
|
|
key="start-blue-work"
|
|
onClick={onStartBlueWork}
|
|
disabled={isTransitioning}
|
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
|
Start Evaluation
|
|
</button>,
|
|
);
|
|
} else {
|
|
// Submit for Review requires ≥1 blue evidence
|
|
const hasBlueEvidence = (test.blue_evidences?.length ?? 0) > 0;
|
|
buttons.push(
|
|
<div key="submit-blue" className="flex flex-col items-end gap-1">
|
|
<button
|
|
onClick={onSubmitBlue}
|
|
disabled={isTransitioning || !hasBlueEvidence}
|
|
title={!hasBlueEvidence ? "Upload at least one Blue Team evidence file before submitting" : undefined}
|
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
Submit for Review
|
|
</button>
|
|
{!hasBlueEvidence && (
|
|
<span className="text-[10px] text-orange-400">⚠ Upload evidence first</span>
|
|
)}
|
|
</div>,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Red Lead in in_review -> Validate Red
|
|
if (
|
|
test.state === "in_review" &&
|
|
role === "red_lead" &&
|
|
!test.red_validation_status
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="validate-red"
|
|
onClick={() => onOpenValidateModal("red")}
|
|
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors"
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
Validate Red Side
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Blue Lead in in_review -> Validate Blue
|
|
if (
|
|
test.state === "in_review" &&
|
|
role === "blue_lead" &&
|
|
!test.blue_validation_status
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="validate-blue"
|
|
onClick={() => onOpenValidateModal("blue")}
|
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
|
|
>
|
|
<Shield className="h-4 w-4" />
|
|
Validate Blue Side
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// Disputed state: symmetric actions for both leads
|
|
if (test.state === "disputed") {
|
|
const isApproving =
|
|
(role === "red_lead" && test.red_validation_status === "approved") ||
|
|
(role === "blue_lead" && test.blue_validation_status === "approved");
|
|
|
|
const isRejecting =
|
|
(role === "red_lead" && test.red_validation_status === "rejected") ||
|
|
(role === "blue_lead" && test.blue_validation_status === "rejected");
|
|
|
|
const rejectingSide: "red" | "blue" =
|
|
test.red_validation_status === "rejected" ? "red" : "blue";
|
|
|
|
if (isApproving) {
|
|
buttons.push(
|
|
<div key="disputed-approver" className="flex flex-col items-end gap-1.5">
|
|
<div className="flex items-center gap-2">
|
|
{/* Discussion request button — shows ✓ after sent */}
|
|
{discussionSent ? (
|
|
<span 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" />
|
|
Discussion Requested ✓
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => { setShowDiscussModal(true); setDiscussResult(null); }}
|
|
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors"
|
|
>
|
|
<MessageSquare className="h-4 w-4" />
|
|
Request Discussion
|
|
</button>
|
|
)}
|
|
{/* Change approving vote to rejected — routes to the team at fault */}
|
|
<button
|
|
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" />
|
|
Change to Rejected
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] text-gray-500">
|
|
You approved — change your vote or request a discussion
|
|
</p>
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
if (isRejecting && !isApproving) {
|
|
buttons.push(
|
|
<div key="disputed-rejector" className="flex flex-col items-end gap-1.5">
|
|
{/* Change rejecting vote to approved */}
|
|
<button
|
|
onClick={() => onOpenValidateModal(rejectingSide)}
|
|
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" />
|
|
Change to Approved
|
|
</button>
|
|
<p className="text-[10px] text-gray-500">
|
|
You rejected — change your vote if you agree after discussion
|
|
</p>
|
|
</div>,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Leads on rejected -> Reopen
|
|
if (
|
|
test.state === "rejected" &&
|
|
(role === "red_lead" || role === "blue_lead")
|
|
) {
|
|
buttons.push(
|
|
<button
|
|
key="reopen"
|
|
onClick={onReopen}
|
|
disabled={isTransitioning}
|
|
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-900/20 px-4 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-900/40 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
|
|
Continue Test
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
// On Hold button — appears alongside action buttons in pre-validation states
|
|
if (canHold && !test.is_on_hold) {
|
|
buttons.push(
|
|
<button
|
|
key="hold"
|
|
onClick={onHold}
|
|
disabled={isTogglingHold}
|
|
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors disabled:opacity-50"
|
|
>
|
|
{isTogglingHold ? <Loader2 className="h-4 w-4 animate-spin" /> : <PauseCircle className="h-4 w-4" />}
|
|
On Hold
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
return buttons.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-2">{buttons}</div>
|
|
) : null;
|
|
};
|
|
|
|
// ── Dual validation indicators ───────────────────────────────────
|
|
|
|
const renderValidationIndicators = () => {
|
|
if (!["in_review", "validated", "rejected", "disputed"].includes(test.state)) {
|
|
return null;
|
|
}
|
|
|
|
const redStatus = test.red_validation_status;
|
|
const blueStatus = test.blue_validation_status;
|
|
|
|
const indicator = (label: string, status: string | null) => {
|
|
if (status === "approved")
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-green-400">
|
|
<CheckCircle className="h-3.5 w-3.5" /> {label}: Approved
|
|
</span>
|
|
);
|
|
if (status === "rejected")
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-red-400">
|
|
<XCircle className="h-3.5 w-3.5" /> {label}: Rejected
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="flex items-center gap-1 text-xs text-gray-500">
|
|
<ShieldCheck className="h-3.5 w-3.5" /> {label}: Pending
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-4">
|
|
{indicator("Red Lead", redStatus)}
|
|
{indicator("Blue Lead", blueStatus)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── Live timer ───────────────────────────────────────────────────
|
|
|
|
const canControlTimer =
|
|
(test.state === "red_executing" && (role === "red_tech" || role === "red_lead")) ||
|
|
(test.state === "blue_evaluating" && (role === "blue_tech" || role === "blue_lead"));
|
|
|
|
const renderLiveTimer = () => {
|
|
if (test.state === "red_executing" && test.red_started_at) {
|
|
return (
|
|
<LiveTimer
|
|
startedAt={test.red_started_at}
|
|
pausedAt={test.paused_at}
|
|
pausedSeconds={test.red_paused_seconds}
|
|
label="Red Team Timer"
|
|
variant="red"
|
|
onPause={onPauseTimer}
|
|
onResume={onResumeTimer}
|
|
canControl={canControlTimer}
|
|
isToggling={isTogglingTimer}
|
|
/>
|
|
);
|
|
}
|
|
if (test.state === "blue_evaluating" && test.blue_work_started_at) {
|
|
return (
|
|
<LiveTimer
|
|
startedAt={test.blue_work_started_at}
|
|
pausedAt={test.paused_at}
|
|
pausedSeconds={test.blue_paused_seconds}
|
|
label="Blue Team Timer"
|
|
variant="blue"
|
|
onPause={onPauseTimer}
|
|
onResume={onResumeTimer}
|
|
canControl={canControlTimer}
|
|
isToggling={isTogglingTimer}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// ── Render ───────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6 space-y-4">
|
|
{/* Top row: name + badge + actions */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="rounded-lg bg-cyan-500/10 p-3">
|
|
<FlaskConical className="h-8 w-8 text-cyan-400" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold text-white">{test.name}</h1>
|
|
<span
|
|
className={`inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${
|
|
STATE_BADGE[test.state]
|
|
}`}
|
|
>
|
|
{getStateLabel(test)}
|
|
</span>
|
|
<DataClassificationBadge
|
|
value={test.data_classification}
|
|
canEdit={["manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)}
|
|
isSaving={isUpdatingClassification}
|
|
onChange={onUpdateClassification}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
Created {formatDate(test.created_at)}
|
|
</p>
|
|
{renderValidationIndicators()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{test.state === "red_review" && (
|
|
<AssigneeControl
|
|
side="red"
|
|
kind="reviewer"
|
|
assigneeId={test.red_reviewer_assignee}
|
|
assigneeUsername={test.red_reviewer_assignee_username}
|
|
operators={operators}
|
|
canEdit={role === "red_lead" || role === "manager"}
|
|
isSaving={isAssigningOperator}
|
|
onAssign={(userId) => onAssignOperator("red_reviewer_assignee", userId)}
|
|
size="lg"
|
|
/>
|
|
)}
|
|
{test.state === "blue_review" && (
|
|
<AssigneeControl
|
|
side="blue"
|
|
kind="reviewer"
|
|
assigneeId={test.blue_reviewer_assignee}
|
|
assigneeUsername={test.blue_reviewer_assignee_username}
|
|
operators={operators}
|
|
canEdit={role === "blue_lead" || role === "manager"}
|
|
isSaving={isAssigningOperator}
|
|
onAssign={(userId) => onAssignOperator("blue_reviewer_assignee", userId)}
|
|
size="lg"
|
|
/>
|
|
)}
|
|
<AssigneeControl
|
|
side="red"
|
|
assigneeId={test.red_tech_assignee}
|
|
assigneeUsername={test.red_tech_assignee_username}
|
|
operators={operators}
|
|
canEdit={role === "red_lead" || role === "manager"}
|
|
isSaving={isAssigningOperator}
|
|
onAssign={(userId) => onAssignOperator("red_tech_assignee", userId)}
|
|
size="lg"
|
|
/>
|
|
<AssigneeControl
|
|
side="blue"
|
|
assigneeId={test.blue_tech_assignee}
|
|
assigneeUsername={test.blue_tech_assignee_username}
|
|
operators={operators}
|
|
canEdit={role === "blue_lead" || role === "manager"}
|
|
isSaving={isAssigningOperator}
|
|
onAssign={(userId) => onAssignOperator("blue_tech_assignee", userId)}
|
|
size="lg"
|
|
/>
|
|
</div>
|
|
{renderLiveTimer()}
|
|
{renderActions()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Disputed conflict banner */}
|
|
{test.state === "disputed" && (
|
|
<div className="mt-3 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
|
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-amber-300">
|
|
Validation Conflict — Leads Disagree
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-amber-400/80">
|
|
One lead approved and the other rejected this test.
|
|
{test.red_validation_status === "rejected" && test.red_validation_notes && (
|
|
<> Red Lead's reason: <span className="italic">"{test.red_validation_notes}"</span></>
|
|
)}
|
|
{test.blue_validation_status === "rejected" && test.blue_validation_notes && (
|
|
<> Blue Lead's reason: <span className="italic">"{test.blue_validation_notes}"</span></>
|
|
)}
|
|
</p>
|
|
<p className="mt-1.5 text-[10px] text-amber-400/60 flex items-center gap-1">
|
|
<MessageSquare className="h-3 w-3" />
|
|
The lead who approved should review the rejection reason and either change their vote or discuss with the other lead to resolve the disagreement.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* On Hold banner */}
|
|
{test.is_on_hold && (
|
|
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/8 p-4">
|
|
<PauseCircle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-amber-300">Test On Hold</p>
|
|
{test.hold_reason && (
|
|
<p className="mt-0.5 text-xs text-amber-400/80">
|
|
<span className="font-medium">Reason:</span> {test.hold_reason}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-[10px] text-amber-400/60">
|
|
This test is paused. No action required until it is resumed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
{test.state !== "rejected" && (
|
|
<div className="pt-2">
|
|
<div className="flex items-center gap-1">
|
|
{PROGRESS_STEPS.map((step, idx) => {
|
|
const isCompleted = idx < currentIdx;
|
|
const isCurrent = idx === currentIdx;
|
|
|
|
return (
|
|
<div key={step.key} className="flex flex-1 flex-col items-center gap-1">
|
|
<div className="flex w-full items-center">
|
|
{/* Connector left */}
|
|
{idx > 0 && (
|
|
<div
|
|
className={`h-0.5 flex-1 ${
|
|
isCompleted || isCurrent ? "bg-cyan-500" : "bg-gray-700"
|
|
}`}
|
|
/>
|
|
)}
|
|
{/* Dot */}
|
|
<div
|
|
className={`h-3 w-3 shrink-0 rounded-full border-2 ${
|
|
isCompleted
|
|
? "border-cyan-500 bg-cyan-500"
|
|
: isCurrent
|
|
? "border-cyan-500 bg-gray-900"
|
|
: "border-gray-600 bg-gray-900"
|
|
}`}
|
|
/>
|
|
{/* Connector right */}
|
|
{idx < PROGRESS_STEPS.length - 1 && (
|
|
<div
|
|
className={`h-0.5 flex-1 ${
|
|
isCompleted ? "bg-cyan-500" : "bg-gray-700"
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={`text-[10px] font-medium ${
|
|
isCurrent ? "text-cyan-400" : isCompleted ? "text-gray-400" : "text-gray-600"
|
|
}`}
|
|
>
|
|
{step.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rejected banner */}
|
|
{test.state === "rejected" && (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
|
This test was rejected and needs to be reopened to restart the workflow.
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirm My Validation — discussion request modal */}
|
|
{showDiscussModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="w-full max-w-lg rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<UserCheck className="h-5 w-5 text-amber-400" />
|
|
<h3 className="text-lg font-semibold text-white">Confirm Your Validation</h3>
|
|
</div>
|
|
<button onClick={() => { setShowDiscussModal(false); setDiscussResult(null); }}
|
|
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white">
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{!discussResult ? (
|
|
<div className="px-6 py-5 space-y-4">
|
|
{/* Conflict summary */}
|
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
|
|
<p className="text-sm font-medium text-amber-300 mb-2">
|
|
⚠ Both leads must agree before a test can be finalised
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
You are confirming that your <strong className="text-white">approval is correct</strong> and
|
|
that the other lead's rejection should be reviewed.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Other lead's rejection reason */}
|
|
{(test.red_validation_status === "rejected" && test.red_validation_notes) && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-gray-500">Red Lead's rejection reason</p>
|
|
<p className="rounded-lg border border-red-500/20 bg-red-900/10 p-3 text-sm italic text-gray-300">
|
|
"{test.red_validation_notes}"
|
|
</p>
|
|
</div>
|
|
)}
|
|
{(test.blue_validation_status === "rejected" && test.blue_validation_notes) && (
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-gray-500">Blue Lead's rejection reason</p>
|
|
<p className="rounded-lg border border-red-500/20 bg-red-900/10 p-3 text-sm italic text-gray-300">
|
|
"{test.blue_validation_notes}"
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* What happens next */}
|
|
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-2">
|
|
<p className="text-xs font-semibold text-white">What happens when you send the discussion request:</p>
|
|
<ul className="space-y-1 text-xs text-gray-400 list-disc list-inside">
|
|
<li>The other lead receives a notification to contact you</li>
|
|
<li>The test <strong className="text-white">remains in Disputed</strong> state</li>
|
|
<li>Either lead can change their vote at any time to resolve it</li>
|
|
<li>If both reject → test becomes Rejected; if both approve → Validated</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{discussMutation.isError && (
|
|
<p className="text-sm text-red-400">Failed to send notification. Try again.</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Success state with contact info */
|
|
<div className="px-6 py-6 space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<CheckCircle className="h-8 w-8 shrink-0 text-green-400" />
|
|
<div>
|
|
<p className="text-base font-semibold text-white">Discussion request sent</p>
|
|
<p className="text-xs text-gray-400">
|
|
{discussResult?.role} has been notified that you want to discuss.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contact card */}
|
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4 space-y-2">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-amber-400">Contact details</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-white">{discussResult?.username}</span>
|
|
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-[10px] text-gray-400">
|
|
{discussResult?.role}
|
|
</span>
|
|
</div>
|
|
{discussResult?.email && (
|
|
<a
|
|
href={`mailto:${discussResult.email}`}
|
|
className="flex items-center gap-1.5 text-xs text-cyan-400 hover:underline"
|
|
>
|
|
✉ {discussResult.email}
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500 leading-relaxed">
|
|
Reach out directly via the contact above or your team's communication channels (Slack, Teams, etc.) to resolve the disagreement.
|
|
The test will remain in <strong className="text-amber-400">Disputed</strong> state until one lead changes their vote.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-3 border-t border-gray-800 px-6 py-4">
|
|
<button
|
|
onClick={() => { setShowDiscussModal(false); setDiscussResult(null); }}
|
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
|
>
|
|
{discussResult ? "Close" : "Cancel"}
|
|
</button>
|
|
{!discussResult && (
|
|
<button
|
|
onClick={() => discussMutation.mutate()}
|
|
disabled={discussMutation.isPending}
|
|
className="flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
|
|
>
|
|
{discussMutation.isPending
|
|
? <><Loader2 className="h-4 w-4 animate-spin" /> Sending…</>
|
|
: <><MessageSquare className="h-4 w-4" /> Send Discussion Request</>
|
|
}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|