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 %} +
+ +
+ +
Close Workspace
+
+
+ {% 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.

{% for row in engagement_review_queue %}{% else %}{% endfor %}
EngagementReview PendingWeighted ProgressSLA / AgeAction
{{ 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
No engagement review dependencies.
+

Engagement Review Dashboard

Prioritised by review dependency, weighted completion and SLA ageing.

{% for row in engagement_review_queue %}{% else %}{% endfor %}
EngagementReview PendingWeighted ProgressSLA / AgeAction
{{ 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
No engagement review dependencies.

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()