diff --git a/app/modules/manager_dashboard/service.py b/app/modules/manager_dashboard/service.py
index 4b24b14..0f50fb5 100644
--- a/app/modules/manager_dashboard/service.py
+++ b/app/modules/manager_dashboard/service.py
@@ -181,6 +181,7 @@ def _load_tasks(db: Session, scope, fy: str | None) -> list[ClientServiceTaskIns
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
+ selectinload(ClientServiceTaskInstance.documents),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
)
)
@@ -463,3 +464,170 @@ def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[
"reports": _report_cards(),
"wizards": _wizard_cards(),
}
+
+
+# ---------------------------------------------------------------------------
+# Phase 5A - engagement-level Manager review workspace.
+# This is a presentation and navigation layer over the existing task review,
+# evidence, comment, rework, AQMM and lock controls. No duplicate review state
+# or review table is introduced.
+# ---------------------------------------------------------------------------
+_MANAGER_REVIEW_PENDING = {"pending", "review_pending", "pending_review", "manager_review", "ready_review"}
+_MANAGER_REVIEW_DONE = {"reviewed"}
+_MANAGER_REVIEW_REWORK = {"rework_required"}
+
+
+def _manager_review_task_state(task: ClientServiceTaskInstance) -> str:
+ manager_status = (getattr(task, "manager_review_status", None) or "not_required").strip().lower()
+ task_status = (getattr(task, "status", None) or "pending").strip().lower()
+ rework_status = (getattr(task, "rework_status", None) or "none").strip().lower()
+ if manager_status in _MANAGER_REVIEW_REWORK or rework_status == "open":
+ return "rework"
+ if manager_status in _MANAGER_REVIEW_DONE:
+ return "reviewed"
+ if manager_status in _MANAGER_REVIEW_PENDING or task_status in REVIEW_STATUSES:
+ return "pending_review"
+ if getattr(task, "aqmm_manager_review_required", False) and task_status in CLOSED_TASK_STATUSES:
+ return "pending_review"
+ return "not_ready"
+
+
+def _manager_review_category(task: ClientServiceTaskInstance) -> str:
+ return (getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
+
+
+def _manager_review_task_row(task: ClientServiceTaskInstance) -> dict[str, Any]:
+ state = _manager_review_task_state(task)
+ comments = [c for c in (getattr(task, "comments", None) or []) if not getattr(c, "is_deleted", False)]
+ documents = list(getattr(task, "documents", None) or [])
+ assignee = getattr(task, "assigned_to", None)
+ return {
+ "id": int(task.id),
+ "task_name": getattr(task, "task_name", None) or "Task",
+ "description": getattr(task, "description", None) or "",
+ "category": _manager_review_category(task),
+ "sequence_no": int(getattr(task, "sequence_no", 0) or 0),
+ "task_status": (getattr(task, "status", None) or "pending").strip().lower(),
+ "task_status_label": _status_label(getattr(task, "status", None)),
+ "review_state": state,
+ "review_state_label": state.replace("_", " ").title(),
+ "manager_review_status": (getattr(task, "manager_review_status", None) or "not_required").strip().lower(),
+ "manager_review_note": getattr(task, "manager_review_note", None) or "",
+ "rework_status": (getattr(task, "rework_status", None) or "none").strip().lower(),
+ "rework_reason": getattr(task, "rework_reason", None) or "",
+ "assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned",
+ "response_type": (getattr(task, "response_type", None) or "NONE").strip().upper(),
+ "response_required": bool(getattr(task, "response_required", False)),
+ "checklist_response": getattr(task, "checklist_response", None),
+ "checklist_text_response": getattr(task, "checklist_text_response", None),
+ "checklist_number_response": getattr(task, "checklist_number_response", None),
+ "checklist_date_response": getattr(task, "checklist_date_response", None),
+ "checklist_remarks": getattr(task, "checklist_remarks", None) or "",
+ "evidence_required": bool(getattr(task, "evidence_required", False) or getattr(task, "aqmm_evidence_required", False)),
+ "evidence_count": len(documents),
+ "comment_count": len(comments),
+ "comments": comments[:20],
+ "is_aqmm_task": bool(getattr(task, "is_aqmm_task", False)),
+ "aqmm_reference": getattr(task, "aqmm_reference", None) or "",
+ "blocks_final_release": bool(getattr(task, "aqmm_blocks_final_release", False)),
+ "is_locked": bool(getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False)),
+ "documents_href": f"/documents/tasks/{task.id}",
+ "task_href": f"/services/work-tracker/tasks/{task.id}/edit",
+ }
+
+
+def get_manager_review_workspace(
+ db: Session,
+ request,
+ current_user,
+ *,
+ subscription_id: int,
+ active_task_id: int | None = None,
+) -> dict[str, Any] | None:
+ """Build one Manager review workspace for an engagement in active scope."""
+ scope = _scope(db, request, current_user)
+ fy = _active_financial_year(request)
+ stmt = (
+ select(ClientServiceTaskInstance)
+ .options(
+ selectinload(ClientServiceTaskInstance.client),
+ selectinload(ClientServiceTaskInstance.catalogue),
+ selectinload(ClientServiceTaskInstance.assigned_to),
+ selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
+ selectinload(ClientServiceTaskInstance.documents),
+ selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
+ )
+ .where(ClientServiceTaskInstance.subscription_id == int(subscription_id))
+ )
+ stmt = _task_scope(stmt, scope, fy)
+ tasks = list(db.execute(stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all())
+ if not tasks:
+ return None
+
+ task_rows = [_manager_review_task_row(task) for task in tasks]
+ reviewable = [row for row in task_rows if row["review_state"] in {"pending_review", "reviewed", "rework"}]
+ pending = [row for row in reviewable if row["review_state"] == "pending_review"]
+ reviewed = [row for row in reviewable if row["review_state"] == "reviewed"]
+ rework = [row for row in reviewable if row["review_state"] == "rework"]
+
+ categories_by_name: dict[str, dict[str, Any]] = {}
+ for row in task_rows:
+ category = categories_by_name.setdefault(row["category"], {
+ "name": row["category"], "tasks": [], "total": 0, "reviewable": 0,
+ "pending": 0, "reviewed": 0, "rework": 0,
+ })
+ category["tasks"].append(row)
+ category["total"] += 1
+ if row["review_state"] in {"pending_review", "reviewed", "rework"}:
+ category["reviewable"] += 1
+ category[row["review_state"].replace("pending_review", "pending")] += 1
+ categories = list(categories_by_name.values())
+ for category in categories:
+ denominator = category["reviewable"] or 0
+ category["progress_percent"] = round(category["reviewed"] * 100 / denominator) if denominator else 0
+ if category["rework"]:
+ category["status"] = "rework"
+ elif category["pending"]:
+ category["status"] = "pending_review"
+ elif denominator and category["reviewed"] == denominator:
+ category["status"] = "reviewed"
+ else:
+ category["status"] = "not_ready"
+
+ active = None
+ if active_task_id:
+ active = next((row for row in task_rows if row["id"] == int(active_task_id)), None)
+ if active is None:
+ active = (pending or rework or reviewed or task_rows)[0]
+
+ first = tasks[0]
+ subscription = getattr(first, "subscription", None)
+ weighted = _weighted_progress(tasks)
+ sla = _engagement_sla(subscription, tasks)
+ team = _engagement_team(subscription)
+ return {
+ "subscription_id": int(subscription_id),
+ "client_name": getattr(getattr(first, "client", None), "client_name", None) or "Client",
+ "service_name": getattr(getattr(first, "catalogue", None), "service_name", None) or "Service",
+ "financial_year": getattr(first, "financial_year", None) or "-",
+ "weighted_progress": weighted,
+ "sla": sla,
+ "team": team,
+ "categories": categories,
+ "tasks": task_rows,
+ "active_task": active,
+ "reviewable_count": len(reviewable),
+ "pending_review_count": len(pending),
+ "reviewed_count": len(reviewed),
+ "rework_count": len(rework),
+ "is_locked": bool(getattr(subscription, "is_locked", False)),
+ }
+
+
+def get_next_manager_review_task_id(workspace: dict[str, Any], current_task_id: int) -> int | None:
+ """Return the next task requiring Manager action, preserving category/sequence order."""
+ tasks = workspace.get("tasks") or []
+ current_index = next((i for i, row in enumerate(tasks) if int(row["id"]) == int(current_task_id)), -1)
+ ordered = tasks[current_index + 1:] + tasks[:max(current_index, 0)]
+ next_row = next((row for row in ordered if row["review_state"] == "pending_review"), None)
+ return int(next_row["id"]) if next_row else None
diff --git a/app/modules/manager_dashboard/templates/manager_dashboard/manager_review_workspace.html b/app/modules/manager_dashboard/templates/manager_dashboard/manager_review_workspace.html
new file mode 100644
index 0000000..513d9a0
--- /dev/null
+++ b/app/modules/manager_dashboard/templates/manager_dashboard/manager_review_workspace.html
@@ -0,0 +1,82 @@
+{% extends "base/layout.html" %}
+{% block content %}
+
+
+
+
+
Manager Engagement Review
+
{{ workspace.client_name }}
+
{{ workspace.service_name }} · {{ workspace.financial_year }}
+
+
+
+
+
Weighted Progress
{{ workspace.weighted_progress.progress_percent }}%
+
Pending Review
{{ workspace.pending_review_count }}
+
Reviewed
{{ workspace.reviewed_count }}
+
Rework
{{ workspace.rework_count }}
+
SLA
{{ workspace.sla.label }}
+
+
+
+ {% if request.query_params.get('reviewed') %}
Manager review saved.
{% endif %}
+ {% if request.query_params.get('review_error') %}
Review could not be saved. Rework requires a reason, and locked or not-ready tasks cannot be reviewed.
{% endif %}
+
+
+
+
+
+ {% set task = workspace.active_task %}
+ {% if task %}
+
+
+
{{ task.category }}
{{ task.task_name }}
{{ task.description }}
+
{{ task.task_status_label }}{% if task.is_aqmm_task %}AQMM{% endif %}{% if task.blocks_final_release %}Final Gate{% endif %}
+
+ Prepared By
{{ task.assigned_to }}
Evidence
{{ task.evidence_count }} document(s)
Comments
{{ task.comment_count }}
Manager Review
{{ task.review_state_label }}
+
+
+
+ Staff Response & Conclusion
- Response Type
- {{ task.response_type }}
- Response
- {{ task.checklist_response or task.checklist_text_response or task.checklist_number_response or task.checklist_date_response or 'Not entered' }}
- Checklist Remarks
- {{ task.checklist_remarks or 'No remarks' }}
{% if task.rework_reason %}- Rework Reason
- {{ task.rework_reason }}
{% endif %}
+ Communication Timeline
{% for comment in task.comments %}
{{ comment.comment_type.replace('_',' ').title() }}{{ comment.created_at_utc }}
{{ comment.message }}
{% else %}
No communication recorded.
{% endfor %}
+
+
+
+ Manager Review Decision
+ {% if task.is_locked or workspace.is_locked %}This task or engagement is locked. Review is read-only.
{% endif %}
+
+
+ {% else %}No tasks are available in this engagement.{% endif %}
+
+
+
+{% endblock %}
diff --git a/app/modules/manager_dashboard/templates/manager_dashboard/partials/review_queue.html b/app/modules/manager_dashboard/templates/manager_dashboard/partials/review_queue.html
index dfbdd0d..745c80e 100644
--- a/app/modules/manager_dashboard/templates/manager_dashboard/partials/review_queue.html
+++ b/app/modules/manager_dashboard/templates/manager_dashboard/partials/review_queue.html
@@ -1,5 +1,5 @@
Engagements Awaiting Review
{{ overview.engagement_review_count }}
Task Review Queue
{{ overview.review_pending_count }}
SLA Breached
{{ overview.sla_breached_count }}
-
Engagement Review Dashboard
Prioritised by review dependency, weighted completion and SLA ageing.
| Engagement | Review Pending | Weighted Progress | SLA / Age | Action |
{% for row in engagement_review_queue %}{{ row.client_name }} {{ row.service_name }} · {{ row.financial_year }} | {{ row.review_pending }} | {{ row.progress_percent }}% | {{ row.sla.label }} {{ row.sla.age_days }} day(s) | Open Workflow |
{% else %}| No engagement review dependencies. |
{% endfor %}
+
Engagement Review Dashboard
Prioritised by review dependency, weighted completion and SLA ageing.
| Engagement | Review Pending | Weighted Progress | SLA / Age | Action |
{% for row in engagement_review_queue %}{{ row.client_name }} {{ row.service_name }} · {{ row.financial_year }} | {{ row.review_pending }} | {{ row.progress_percent }}% | {{ row.sla.label }} {{ row.sla.age_days }} day(s) | Start / Continue Review |
{% else %}| No engagement review dependencies. |
{% endfor %}
Detailed Task Review Queue
Existing task-level review workflow remains available.
{% set rows = review_queue %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}
diff --git a/app/modules/manager_dashboard/ui.py b/app/modules/manager_dashboard/ui.py
index 2582a32..ca18cd1 100644
--- a/app/modules/manager_dashboard/ui.py
+++ b/app/modules/manager_dashboard/ui.py
@@ -1,15 +1,21 @@
from __future__ import annotations
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from app.core.db.common import CommonSessionLocal
from app.core.http_responses import ui_access_denied
-from app.core.security.csrf import get_or_create_csrf_token
+from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
-from app.modules.manager_dashboard.service import build_manager_dashboard_payload, can_access_manager_dashboard
+from app.modules.manager_dashboard.service import (
+ build_manager_dashboard_payload,
+ can_access_manager_dashboard,
+ get_manager_review_workspace,
+ get_next_manager_review_task_id,
+)
+from app.modules.services.execution import apply_task_review
router = APIRouter(prefix="/manager", tags=["manager-dashboard-v2-ui"])
@@ -74,3 +80,103 @@ def dashboard_tab(request: Request, tab_name: str):
)
finally:
db.close()
+
+
+@router.get("/reviews/engagements/{subscription_id}")
+def manager_review_workspace(request: Request, subscription_id: int, task_id: int | None = None):
+ db = CommonSessionLocal()
+ try:
+ current_user = get_current_user(request, db=db)
+ if not current_user:
+ return RedirectResponse(url="/login", status_code=303)
+ if not can_access_manager_dashboard(db, current_user):
+ return ui_access_denied()
+ workspace = get_manager_review_workspace(
+ db, request, current_user,
+ subscription_id=subscription_id,
+ active_task_id=task_id,
+ )
+ if not workspace:
+ return RedirectResponse(url="/manager/dashboard?tab=review-queue", status_code=303)
+ return templates.TemplateResponse(
+ "modules/manager_dashboard/templates/manager_dashboard/manager_review_workspace.html",
+ {
+ "request": request,
+ "current_user": current_user,
+ "current_user_roles": get_user_roles(db, current_user.id),
+ "current_user_permissions": get_user_permissions(db, current_user.id),
+ "csrf_token": get_or_create_csrf_token(request),
+ "title": "Manager Engagement Review",
+ "workspace": workspace,
+ },
+ )
+ finally:
+ db.close()
+
+
+@router.post("/reviews/engagements/{subscription_id}/tasks/{task_id}")
+def manager_review_task_submit(
+ request: Request,
+ subscription_id: int,
+ task_id: int,
+ decision: str = Form("reviewed"),
+ review_note: str = Form(""),
+ workflow_action: str = Form("save"),
+ csrf_token: str = Form(...),
+):
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ current_user = get_current_user(request, db=db)
+ if not current_user:
+ return RedirectResponse(url="/login", status_code=303)
+ if not can_access_manager_dashboard(db, current_user):
+ return ui_access_denied()
+ workspace = get_manager_review_workspace(
+ db, request, current_user,
+ subscription_id=subscription_id,
+ active_task_id=task_id,
+ )
+ if not workspace:
+ return RedirectResponse(url="/manager/dashboard?tab=review-queue", status_code=303)
+ active = workspace.get("active_task") or {}
+ if int(active.get("id") or 0) != int(task_id):
+ return RedirectResponse(url=f"/manager/reviews/engagements/{subscription_id}?review_error=task", status_code=303)
+ if active.get("is_locked") or workspace.get("is_locked"):
+ return RedirectResponse(url=f"/manager/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=locked", status_code=303)
+ if active.get("review_state") not in {"pending_review", "reviewed", "rework"}:
+ return RedirectResponse(url=f"/manager/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=not_ready", status_code=303)
+
+ # Reload the ORM object from the scoped workspace query result.
+ from app.modules.services.models import ClientServiceTaskInstance
+ from sqlalchemy import select
+ task = db.execute(
+ select(ClientServiceTaskInstance).where(
+ ClientServiceTaskInstance.id == int(task_id),
+ ClientServiceTaskInstance.subscription_id == int(subscription_id),
+ )
+ ).scalar_one_or_none()
+ if not task:
+ return RedirectResponse(url="/manager/dashboard?tab=review-queue", status_code=303)
+ try:
+ apply_task_review(
+ db,
+ task=task,
+ review_level="manager",
+ decision=decision,
+ note=review_note,
+ user_id=current_user.id,
+ )
+ db.commit()
+ except ValueError:
+ db.rollback()
+ return RedirectResponse(url=f"/manager/reviews/engagements/{subscription_id}?task_id={task_id}&review_error=validation", status_code=303)
+
+ if workflow_action == "save_next":
+ refreshed = get_manager_review_workspace(db, request, current_user, subscription_id=subscription_id)
+ next_id = get_next_manager_review_task_id(refreshed, task_id) if refreshed else None
+ if next_id:
+ return RedirectResponse(url=f"/manager/reviews/engagements/{subscription_id}?task_id={next_id}&reviewed=1", status_code=303)
+ return RedirectResponse(url=f"/manager/reviews/engagements/{subscription_id}?task_id={task_id}&reviewed=1", status_code=303)
+ finally:
+ db.close()