feat(phase-14): redesign Test Detail page with Red/Blue tabs and dual validation (T-115, T-116, T-117, T-118)

T-115: TestDetailHeader with progress bar, contextual action buttons, and dual validation indicators

T-116: TeamTabs component with Red Team, Blue Team, Summary, and Timeline tabs

T-117: Redesigned TestDetailPage integrating new components with react-query mutations, toast notifications, and role/state-based permissions

T-118: ValidationModal for dual Red Lead / Blue Lead approval with required notes on rejection
This commit is contained in:
2026-02-09 11:14:44 +01:00
parent d660bceeb4
commit cea470053f
4 changed files with 1434 additions and 232 deletions

View File

@@ -0,0 +1,329 @@
import {
FlaskConical,
Play,
Send,
CheckCircle,
XCircle,
RotateCcw,
Loader2,
Shield,
ShieldCheck,
} from "lucide-react";
import type { Test, TestState, User } from "../../types/models";
// ── Progress steps ─────────────────────────────────────────────────
const PROGRESS_STEPS: { key: TestState; label: string }[] = [
{ key: "draft", label: "Draft" },
{ key: "red_executing", label: "Red Exec" },
{ key: "blue_evaluating", label: "Blue Eval" },
{ key: "in_review", label: "Review" },
{ key: "validated", label: "Validated" },
];
const STATE_INDEX: Record<TestState, number> = {
draft: 0,
red_executing: 1,
blue_evaluating: 2,
in_review: 3,
validated: 4,
rejected: -1,
};
// ── 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",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-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",
};
// ── Props ──────────────────────────────────────────────────────────
interface TestDetailHeaderProps {
test: Test;
user: User | null;
isTransitioning: boolean;
onStartExecution: () => void;
onSubmitRed: () => void;
onSubmitBlue: () => void;
onOpenValidateModal: (side: "red" | "blue") => void;
onReopen: () => void;
}
// ── Component ──────────────────────────────────────────────────────
export default function TestDetailHeader({
test,
user,
isTransitioning,
onStartExecution,
onSubmitRed,
onSubmitBlue,
onOpenValidateModal,
onReopen,
}: TestDetailHeaderProps) {
const role = user?.role ?? "";
const currentIdx = STATE_INDEX[test.state];
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 renderActions = () => {
const buttons: React.ReactNode[] = [];
// Red Tech in draft -> Start Execution
if (
test.state === "draft" &&
(role === "red_tech" || role === "admin")
) {
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 Tech in red_executing -> Submit to Blue Team
if (
test.state === "red_executing" &&
(role === "red_tech" || role === "admin")
) {
buttons.push(
<button
key="submit-red"
onClick={onSubmitRed}
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" /> : <Send className="h-4 w-4" />}
Submit to Blue Team
</button>,
);
}
// Blue Tech in blue_evaluating -> Submit for Review
if (
test.state === "blue_evaluating" &&
(role === "blue_tech" || role === "admin")
) {
buttons.push(
<button
key="submit-blue"
onClick={onSubmitBlue}
disabled={isTransitioning}
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"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Submit for Review
</button>,
);
}
// Red Lead in in_review -> Validate Red
if (
test.state === "in_review" &&
(role === "red_lead" || role === "admin") &&
!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" || role === "admin") &&
!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>,
);
}
// Leads/admin on rejected -> Reopen
if (
test.state === "rejected" &&
(role === "red_lead" || role === "blue_lead" || role === "admin")
) {
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" />}
Reopen Test
</button>,
);
}
return buttons.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">{buttons}</div>
) : null;
};
// ── Dual validation indicators ───────────────────────────────────
const renderValidationIndicators = () => {
if (test.state !== "in_review" && test.state !== "validated" && test.state !== "rejected") {
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>
);
};
// ── 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]
}`}
>
{test.state.replace(/_/g, " ")}
</span>
</div>
<p className="mt-1 text-sm text-gray-400">
Created {formatDate(test.created_at)}
</p>
{renderValidationIndicators()}
</div>
</div>
{renderActions()}
</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>
)}
</div>
);
}