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:
@@ -672,6 +672,27 @@ class TestEntity:
|
|||||||
|
|
||||||
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
|
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
|
||||||
|
|
||||||
|
def resolve_dispute_by_manager(self, outcome: str) -> None:
|
||||||
|
"""A manager makes the final call on a disputed test, ending the standoff.
|
||||||
|
|
||||||
|
Unlike ``resolve_dispute_reject`` (a lead flipping their own vote),
|
||||||
|
this bypasses both leads entirely — the manager's decision is final
|
||||||
|
and terminal, going straight to ``validated`` or the normal
|
||||||
|
``rejected`` dead-end (which any lead can later reopen via the
|
||||||
|
standard reopen-to-draft flow, same as any other rejection).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outcome (str): ``"validated"`` or ``"rejected"``.
|
||||||
|
"""
|
||||||
|
if outcome not in ("validated", "rejected"):
|
||||||
|
raise InvalidOperationError("outcome must be 'validated' or 'rejected'")
|
||||||
|
|
||||||
|
target_state = TestState.validated if outcome == "validated" else TestState.rejected
|
||||||
|
self._transition(target_state)
|
||||||
|
self._events.append(
|
||||||
|
DomainEvent("dual_validation_approved" if outcome == "validated" else "dual_validation_rejected")
|
||||||
|
)
|
||||||
|
|
||||||
# -- Private -------------------------------------------------------
|
# -- Private -------------------------------------------------------
|
||||||
|
|
||||||
def _auto_resume(self) -> int:
|
def _auto_resume(self) -> int:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ from app.schemas.test import (
|
|||||||
TestClassificationUpdate,
|
TestClassificationUpdate,
|
||||||
TestCreate,
|
TestCreate,
|
||||||
TestHold,
|
TestHold,
|
||||||
|
TestManagerResolveDispute,
|
||||||
TestOut,
|
TestOut,
|
||||||
TestRedReview,
|
TestRedReview,
|
||||||
TestRedUpdate,
|
TestRedUpdate,
|
||||||
@@ -152,6 +153,7 @@ from app.services.test_workflow_service import (
|
|||||||
validate_as_red_lead as wf_validate_red,
|
validate_as_red_lead as wf_validate_red,
|
||||||
validate_as_blue_lead as wf_validate_blue,
|
validate_as_blue_lead as wf_validate_blue,
|
||||||
resolve_dispute as wf_resolve_dispute,
|
resolve_dispute as wf_resolve_dispute,
|
||||||
|
resolve_dispute_by_manager as wf_resolve_dispute_by_manager,
|
||||||
reopen_test as wf_reopen,
|
reopen_test as wf_reopen,
|
||||||
handle_remediation_completed as wf_handle_remediation,
|
handle_remediation_completed as wf_handle_remediation,
|
||||||
get_retest_chain as wf_get_retest_chain,
|
get_retest_chain as wf_get_retest_chain,
|
||||||
@@ -1192,6 +1194,78 @@ def resolve_dispute(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/escalate-to-manager — disputed: notify managers to step in
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/escalate-to-manager")
|
||||||
|
def escalate_to_manager(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
|
):
|
||||||
|
"""Either lead on a disputed test can escalate it for a manager to decide.
|
||||||
|
|
||||||
|
Unlike the automatic manager notification sent when a test first becomes
|
||||||
|
disputed, this is an explicit, repeatable nudge — a lead pulls this when
|
||||||
|
peer discussion (Request Discussion) isn't going anywhere.
|
||||||
|
"""
|
||||||
|
from app.services.notification_service import notify_role_with_email
|
||||||
|
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if test.state.value != "disputed":
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
raise BusinessRuleViolation("Test is not in disputed state")
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_role_with_email(
|
||||||
|
db,
|
||||||
|
role="manager",
|
||||||
|
type="validation_disputed",
|
||||||
|
title="Dispute escalated — needs your decision",
|
||||||
|
message=(
|
||||||
|
f'{current_user.username} escalated test "{test.name}" — the leads could not '
|
||||||
|
f'resolve their disagreement. Please review and decide the final outcome.'
|
||||||
|
),
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("Failed to notify managers of escalation: %s", e)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="escalate_dispute_to_manager",
|
||||||
|
entity_type="test", entity_id=test.id, details={"test_name": test.name},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "escalated", "message": "Managers have been notified"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/manager-resolve-dispute — manager makes the final call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/manager-resolve-dispute", response_model=TestOut)
|
||||||
|
def manager_resolve_dispute(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestManagerResolveDispute,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("manager")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""A manager decides the final outcome of a disputed test directly."""
|
||||||
|
test = crud_get_test_with_technique(db, test_id)
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
test = wf_resolve_dispute_by_manager(db, test, current_user, payload.outcome, notes=payload.notes)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/reopen — rejected → draft
|
# POST /tests/{id}/reopen — rejected → draft
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ class TestResolveDispute(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerResolveDispute(BaseModel):
|
||||||
|
"""Payload sent by a manager making the final call on a disputed test."""
|
||||||
|
|
||||||
|
outcome: str # "validated" | "rejected"
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1341,6 +1341,50 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dispute_by_manager(db: Session, test: Test, user: User, outcome: str, notes: str | None = None) -> Test:
|
||||||
|
"""A manager makes the final call on a disputed test — validated or rejected.
|
||||||
|
|
||||||
|
Unlike ``resolve_dispute`` (only the approving lead flipping their own
|
||||||
|
vote), this is a manager override that ends the standoff directly,
|
||||||
|
without either lead having to concede. Reuses the same dual-validation
|
||||||
|
event dispatch as a normal in_review resolution so Jira/notifications
|
||||||
|
behave identically to an ordinary validated/rejected outcome.
|
||||||
|
"""
|
||||||
|
if test.state != TestState.disputed:
|
||||||
|
raise InvalidOperationError("Test is not in disputed state")
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.resolve_dispute_by_manager(outcome)
|
||||||
|
entity.apply_to(test)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="manager_resolve_dispute",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"outcome": outcome, "notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
||||||
|
|
||||||
|
# Let both leads know the manager made the final call, not just whichever
|
||||||
|
# side "won" — the losing side needs to know just as much.
|
||||||
|
for lead_id in filter(None, {test.red_validated_by, test.blue_validated_by}):
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=lead_id, type="manager_dispute_resolution",
|
||||||
|
title=f"Manager resolved disputed test as {outcome}",
|
||||||
|
message=(
|
||||||
|
f'{user.username} resolved the dispute on "{test.name}" as {outcome}.'
|
||||||
|
+ (f" Notes: {notes}" if notes else "")
|
||||||
|
),
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# Define function handle_remediation_completed
|
# Define function handle_remediation_completed
|
||||||
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
||||||
"""Create a re-test when remediation is completed.
|
"""Create a re-test when remediation is completed.
|
||||||
|
|||||||
@@ -112,3 +112,96 @@ def test_resolve_dispute_routes_to_red_queue(
|
|||||||
assert body["state"] == "red_executing"
|
assert body["state"] == "red_executing"
|
||||||
assert body["red_validation_status"] is None
|
assert body["red_validation_status"] is None
|
||||||
assert body["blue_validation_status"] is None
|
assert body["blue_validation_status"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_to_manager_notifies_managers(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, manager_headers, manager_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["status"] == "escalated"
|
||||||
|
|
||||||
|
notifications = api("get", "/api/v1/notifications", manager_headers).json()
|
||||||
|
assert any("escalated" in (n.get("title") or "").lower() for n in notifications)
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalate_to_manager_requires_disputed_state(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, technique,
|
||||||
|
):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
|
json={"technique_id": technique, "name": "Not disputed"},
|
||||||
|
)
|
||||||
|
test_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_resolve_dispute_as_validated(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, manager_headers, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
|
||||||
|
json={"outcome": "validated", "notes": "Detection was sufficient after all"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["state"] == "validated"
|
||||||
|
|
||||||
|
red_notifs = api("get", "/api/v1/notifications", red_lead_headers).json()
|
||||||
|
assert any(n["type"] == "manager_dispute_resolution" for n in red_notifs)
|
||||||
|
blue_notifs = api("get", "/api/v1/notifications", blue_lead_headers).json()
|
||||||
|
assert any(n["type"] == "manager_dispute_resolution" for n in blue_notifs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_resolve_dispute_as_rejected(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, manager_headers, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
|
||||||
|
json={"outcome": "rejected"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["state"] == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_resolve_dispute_forbidden_for_lead(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", red_lead_headers,
|
||||||
|
json={"outcome": "validated"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_resolve_dispute_requires_disputed_state(
|
||||||
|
client, db, api, red_lead_headers, manager_headers, technique,
|
||||||
|
):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
|
json={"technique_id": technique, "name": "Not disputed either"},
|
||||||
|
)
|
||||||
|
test_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
|
||||||
|
json={"outcome": "validated"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|||||||
@@ -319,6 +319,26 @@ export async function resolveDispute(testId: string, payload: ResolveDisputePayl
|
|||||||
return data;
|
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 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Reopen a rejected test — moves back to draft. */
|
/** 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,
|
PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { Test, TestState, User, DataClassification } from "../../types/models";
|
||||||
import type { OperatorOut } from "../../api/users";
|
import type { OperatorOut } from "../../api/users";
|
||||||
import LiveTimer from "./LiveTimer";
|
import LiveTimer from "./LiveTimer";
|
||||||
@@ -83,6 +83,7 @@ interface TestDetailHeaderProps {
|
|||||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||||
onOpenReviewModal: (side: "red" | "blue") => void;
|
onOpenReviewModal: (side: "red" | "blue") => void;
|
||||||
onOpenResolveDisputeModal: () => void;
|
onOpenResolveDisputeModal: () => void;
|
||||||
|
onOpenManagerDecisionModal: (outcome: "validated" | "rejected") => void;
|
||||||
onReopen: () => void;
|
onReopen: () => void;
|
||||||
onPauseTimer: () => void;
|
onPauseTimer: () => void;
|
||||||
onResumeTimer: () => void;
|
onResumeTimer: () => void;
|
||||||
@@ -113,6 +114,7 @@ export default function TestDetailHeader({
|
|||||||
onOpenValidateModal,
|
onOpenValidateModal,
|
||||||
onOpenReviewModal,
|
onOpenReviewModal,
|
||||||
onOpenResolveDisputeModal,
|
onOpenResolveDisputeModal,
|
||||||
|
onOpenManagerDecisionModal,
|
||||||
onReopen,
|
onReopen,
|
||||||
onPauseTimer,
|
onPauseTimer,
|
||||||
onResumeTimer,
|
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) => {
|
const formatDate = (d: string | null) => {
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
return new Date(d).toLocaleDateString("en-US", {
|
return new Date(d).toLocaleDateString("en-US", {
|
||||||
@@ -403,6 +411,58 @@ export default function TestDetailHeader({
|
|||||||
</div>,
|
</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
|
// Leads on rejected -> Reopen
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
reviewAsRedLead,
|
reviewAsRedLead,
|
||||||
reviewAsBlueLead,
|
reviewAsBlueLead,
|
||||||
resolveDispute,
|
resolveDispute,
|
||||||
|
managerResolveDispute,
|
||||||
reopenTest,
|
reopenTest,
|
||||||
pauseTimer,
|
pauseTimer,
|
||||||
resumeTimer,
|
resumeTimer,
|
||||||
@@ -37,6 +38,7 @@ import TeamTabs from "../components/test-detail/TeamTabs";
|
|||||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||||
import ReviewModal from "../components/test-detail/ReviewModal";
|
import ReviewModal from "../components/test-detail/ReviewModal";
|
||||||
import ResolveDisputeModal from "../components/test-detail/ResolveDisputeModal";
|
import ResolveDisputeModal from "../components/test-detail/ResolveDisputeModal";
|
||||||
|
import ManagerDecisionModal from "../components/test-detail/ManagerDecisionModal";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||||
@@ -90,6 +92,11 @@ export default function TestDetailPage() {
|
|||||||
|
|
||||||
const [resolveDisputeModalOpen, setResolveDisputeModalOpen] = useState(false);
|
const [resolveDisputeModalOpen, setResolveDisputeModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [managerDecisionModal, setManagerDecisionModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
outcome: "validated" | "rejected";
|
||||||
|
}>({ open: false, outcome: "validated" });
|
||||||
|
|
||||||
const [confirmReopen, setConfirmReopen] = useState(false);
|
const [confirmReopen, setConfirmReopen] = useState(false);
|
||||||
const [holdModal, setHoldModal] = useState(false);
|
const [holdModal, setHoldModal] = useState(false);
|
||||||
const [holdReason, setHoldReason] = useState("");
|
const [holdReason, setHoldReason] = useState("");
|
||||||
@@ -346,6 +353,17 @@ export default function TestDetailPage() {
|
|||||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
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({
|
const reopenMutation = useMutation({
|
||||||
mutationFn: () => reopenTest(testId!),
|
mutationFn: () => reopenTest(testId!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -585,6 +603,7 @@ export default function TestDetailPage() {
|
|||||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||||
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
||||||
onOpenResolveDisputeModal={() => setResolveDisputeModalOpen(true)}
|
onOpenResolveDisputeModal={() => setResolveDisputeModalOpen(true)}
|
||||||
|
onOpenManagerDecisionModal={(outcome) => setManagerDecisionModal({ open: true, outcome })}
|
||||||
onReopen={() => setConfirmReopen(true)}
|
onReopen={() => setConfirmReopen(true)}
|
||||||
onPauseTimer={() => pauseTimerMutation.mutate()}
|
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||||
onResumeTimer={() => resumeTimerMutation.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 */}
|
{/* Save as Template Modal */}
|
||||||
{showTemplateModal && (
|
{showTemplateModal && (
|
||||||
<SaveAsTemplateModal
|
<SaveAsTemplateModal
|
||||||
|
|||||||
Reference in New Issue
Block a user