Add manager engagement review workspace phase 5A

This commit is contained in:
A R R R Associates
2026-07-20 12:54:10 +05:30
parent fe23b2b253
commit 4c81659156
4 changed files with 360 additions and 4 deletions
+168
View File
@@ -181,6 +181,7 @@ def _load_tasks(db: Session, scope, fy: str | None) -> list[ClientServiceTaskIns
selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
selectinload(ClientServiceTaskInstance.documents),
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
) )
) )
@@ -463,3 +464,170 @@ def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[
"reports": _report_cards(), "reports": _report_cards(),
"wizards": _wizard_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
@@ -0,0 +1,82 @@
{% extends "base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-[1600px] space-y-5">
<header class="rounded-2xl bg-slate-900 p-5 text-white shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-slate-300">Manager Engagement Review</p>
<h1 class="mt-1 text-2xl font-semibold">{{ workspace.client_name }}</h1>
<p class="mt-1 text-sm text-slate-300">{{ workspace.service_name }} · {{ workspace.financial_year }}</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/manager/dashboard?tab=review-queue" class="rounded-xl border border-white/30 px-4 py-2 text-sm font-semibold hover:bg-white/10">Close Workspace</a>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-xl bg-white/10 p-3"><div class="text-xs text-slate-300">Weighted Progress</div><div class="mt-1 text-xl font-semibold">{{ workspace.weighted_progress.progress_percent }}%</div></div>
<div class="rounded-xl bg-white/10 p-3"><div class="text-xs text-slate-300">Pending Review</div><div class="mt-1 text-xl font-semibold">{{ workspace.pending_review_count }}</div></div>
<div class="rounded-xl bg-white/10 p-3"><div class="text-xs text-slate-300">Reviewed</div><div class="mt-1 text-xl font-semibold">{{ workspace.reviewed_count }}</div></div>
<div class="rounded-xl bg-white/10 p-3"><div class="text-xs text-slate-300">Rework</div><div class="mt-1 text-xl font-semibold">{{ workspace.rework_count }}</div></div>
<div class="rounded-xl bg-white/10 p-3"><div class="text-xs text-slate-300">SLA</div><div class="mt-1 text-sm font-semibold">{{ workspace.sla.label }}</div></div>
</div>
</header>
{% if request.query_params.get('reviewed') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-800">Manager review saved.</div>{% endif %}
{% if request.query_params.get('review_error') %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-800">Review could not be saved. Rework requires a reason, and locked or not-ready tasks cannot be reviewed.</div>{% endif %}
<section class="grid gap-5 xl:grid-cols-[390px_minmax(0,1fr)]">
<aside class="space-y-4">
<div class="af-card">
<h2 class="font-semibold text-slate-900">Engagement Team</h2>
<div class="mt-3 space-y-2 text-sm">{% for member in workspace.team %}<div class="flex justify-between gap-3"><span class="text-slate-500">{{ member.role }}</span><span class="font-semibold text-right">{{ member.name }}</span></div>{% endfor %}</div>
</div>
<div class="space-y-3">
{% for category in workspace.categories %}
<details class="af-card p-0 overflow-hidden" {% if workspace.active_task and workspace.active_task.category == category.name %}open{% endif %}>
<summary class="cursor-pointer list-none p-4">
<div class="flex items-start justify-between gap-3"><div><div class="font-semibold text-slate-900">{{ category.name }}</div><div class="mt-1 text-xs text-slate-500">{{ category.reviewed }} reviewed · {{ category.pending }} pending · {{ category.rework }} rework</div></div><span class="rounded-full px-2 py-1 text-xs font-semibold {% if category.status == 'reviewed' %}bg-emerald-100 text-emerald-700{% elif category.status == 'rework' %}bg-red-100 text-red-700{% elif category.status == 'pending_review' %}bg-violet-100 text-violet-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ category.progress_percent }}%</span></div>
</summary>
<div class="border-t border-slate-100 p-2">
{% for task in category.tasks %}
<a href="/manager/reviews/engagements/{{ workspace.subscription_id }}?task_id={{ task.id }}" class="mb-1 block rounded-xl border px-3 py-2 text-sm {% if workspace.active_task and workspace.active_task.id == task.id %}border-indigo-400 bg-indigo-50{% else %}border-transparent hover:border-slate-200 hover:bg-slate-50{% endif %}">
<div class="font-medium text-slate-900">{{ task.task_name }}</div>
<div class="mt-1 flex flex-wrap gap-1 text-[11px]"><span class="rounded bg-slate-100 px-1.5 py-0.5">{{ task.assigned_to }}</span><span class="rounded px-1.5 py-0.5 {% if task.review_state == 'pending_review' %}bg-violet-100 text-violet-700{% elif task.review_state == 'reviewed' %}bg-emerald-100 text-emerald-700{% elif task.review_state == 'rework' %}bg-red-100 text-red-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ task.review_state_label }}</span></div>
</a>
{% endfor %}
</div>
</details>
{% endfor %}
</div>
</aside>
<main class="space-y-4">
{% set task = workspace.active_task %}
{% if task %}
<section class="af-card">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><div class="text-xs font-semibold uppercase text-slate-500">{{ task.category }}</div><h2 class="mt-1 text-xl font-semibold text-slate-900">{{ task.task_name }}</h2><p class="mt-2 whitespace-pre-line text-sm text-slate-600">{{ task.description }}</p></div>
<div class="flex flex-wrap gap-2"><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold">{{ task.task_status_label }}</span>{% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">AQMM</span>{% endif %}{% if task.blocks_final_release %}<span class="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700">Final Gate</span>{% endif %}</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Prepared By</div><div class="mt-1 font-semibold">{{ task.assigned_to }}</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Evidence</div><div class="mt-1 font-semibold">{{ task.evidence_count }} document(s)</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Comments</div><div class="mt-1 font-semibold">{{ task.comment_count }}</div></div><div class="rounded-xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Manager Review</div><div class="mt-1 font-semibold">{{ task.review_state_label }}</div></div></div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="af-card"><h3 class="font-semibold text-slate-900">Staff Response & Conclusion</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-xs uppercase text-slate-500">Response Type</dt><dd class="font-semibold">{{ task.response_type }}</dd></div><div><dt class="text-xs uppercase text-slate-500">Response</dt><dd class="font-semibold">{{ task.checklist_response or task.checklist_text_response or task.checklist_number_response or task.checklist_date_response or 'Not entered' }}</dd></div><div><dt class="text-xs uppercase text-slate-500">Checklist Remarks</dt><dd class="whitespace-pre-line">{{ task.checklist_remarks or 'No remarks' }}</dd></div>{% if task.rework_reason %}<div class="rounded-xl border border-red-200 bg-red-50 p-3"><dt class="text-xs uppercase text-red-600">Rework Reason</dt><dd class="mt-1 whitespace-pre-line text-red-800">{{ task.rework_reason }}</dd></div>{% endif %}</dl><div class="mt-4 flex gap-2"><a href="{{ task.documents_href }}" class="af-btn af-btn-secondary">Open Evidence</a><a href="{{ task.task_href }}" class="af-btn af-btn-secondary">Open Full Task</a></div></div>
<div class="af-card"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-4 max-h-80 space-y-3 overflow-y-auto">{% for comment in task.comments %}<div class="rounded-xl bg-slate-50 p-3"><div class="flex justify-between gap-3 text-xs text-slate-500"><span>{{ comment.comment_type.replace('_',' ').title() }}</span><span>{{ comment.created_at_utc }}</span></div><p class="mt-2 whitespace-pre-line text-sm text-slate-700">{{ comment.message }}</p></div>{% else %}<p class="text-sm text-slate-500">No communication recorded.</p>{% endfor %}</div></div>
</section>
<section class="af-card">
<h3 class="font-semibold text-slate-900">Manager Review Decision</h3>
{% if task.is_locked or workspace.is_locked %}<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">This task or engagement is locked. Review is read-only.</div>{% endif %}
<form method="post" action="/manager/reviews/engagements/{{ workspace.subscription_id }}/tasks/{{ task.id }}" class="mt-4 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid gap-3 md:grid-cols-2"><label class="block"><span class="text-sm font-semibold text-slate-700">Decision</span><select name="decision" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}><option value="reviewed">Approve / Reviewed</option><option value="rework_required">Return for Rework</option></select></label><label class="block"><span class="text-sm font-semibold text-slate-700">Review state</span><input class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2" value="{{ task.review_state_label }}" readonly></label></div>
<label class="block"><span class="text-sm font-semibold text-slate-700">Manager review note</span><textarea name="review_note" rows="5" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Required when returning for rework; recommended for significant conclusions." {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>{{ task.manager_review_note }}</textarea></label>
<div class="flex flex-wrap gap-2"><button type="submit" name="workflow_action" value="save" class="af-btn af-btn-secondary" {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>Save Review</button><button type="submit" name="workflow_action" value="save_next" class="af-btn af-btn-primary" {% if task.is_locked or workspace.is_locked or task.review_state == 'not_ready' %}disabled{% endif %}>Save &amp; Next Review</button><a href="/manager/dashboard?tab=review-queue" class="af-btn af-btn-secondary">Close Workspace</a></div>
</form>
</section>
{% else %}<section class="af-card text-sm text-slate-500">No tasks are available in this engagement.</section>{% endif %}
</main>
</section>
</div>
{% endblock %}
@@ -1,5 +1,5 @@
<div class="space-y-6"> <div class="space-y-6">
<section class="grid gap-4 md:grid-cols-3"><div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Engagements Awaiting Review</div><div class="mt-2 text-3xl font-semibold">{{ overview.engagement_review_count }}</div></div><div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Task Review Queue</div><div class="mt-2 text-3xl font-semibold">{{ overview.review_pending_count }}</div></div><div class="af-metric-card border-red-200 bg-red-50"><div class="text-xs font-semibold uppercase text-red-700">SLA Breached</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ overview.sla_breached_count }}</div></div></section> <section class="grid gap-4 md:grid-cols-3"><div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Engagements Awaiting Review</div><div class="mt-2 text-3xl font-semibold">{{ overview.engagement_review_count }}</div></div><div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Task Review Queue</div><div class="mt-2 text-3xl font-semibold">{{ overview.review_pending_count }}</div></div><div class="af-metric-card border-red-200 bg-red-50"><div class="text-xs font-semibold uppercase text-red-700">SLA Breached</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ overview.sla_breached_count }}</div></div></section>
<section class="af-card overflow-x-auto"><div class="mb-4"><h3 class="text-lg font-semibold text-slate-900">Engagement Review Dashboard</h3><p class="text-sm text-slate-500">Prioritised by review dependency, weighted completion and SLA ageing.</p></div><table class="min-w-full text-sm"><thead><tr class="border-b text-left text-xs uppercase text-slate-500"><th class="px-3 py-2">Engagement</th><th class="px-3 py-2">Review Pending</th><th class="px-3 py-2">Weighted Progress</th><th class="px-3 py-2">SLA / Age</th><th class="px-3 py-2">Action</th></tr></thead><tbody>{% for row in engagement_review_queue %}<tr class="border-b border-slate-100"><td class="px-3 py-3"><div class="font-semibold">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.service_name }} · {{ row.financial_year }}</div></td><td class="px-3 py-3 font-semibold text-violet-700">{{ row.review_pending }}</td><td class="px-3 py-3">{{ row.progress_percent }}%</td><td class="px-3 py-3"><div class="{% if row.sla.status == 'breached' %}text-red-700{% else %}text-amber-700{% endif %}">{{ row.sla.label }}</div><div class="text-xs text-slate-500">{{ row.sla.age_days }} day(s)</div></td><td class="px-3 py-3"><a href="{{ row.href }}" class="af-btn af-btn-primary">Open Workflow</a></td></tr>{% else %}<tr><td colspan="5" class="px-3 py-8 text-center text-slate-500">No engagement review dependencies.</td></tr>{% endfor %}</tbody></table></section> <section class="af-card overflow-x-auto"><div class="mb-4"><h3 class="text-lg font-semibold text-slate-900">Engagement Review Dashboard</h3><p class="text-sm text-slate-500">Prioritised by review dependency, weighted completion and SLA ageing.</p></div><table class="min-w-full text-sm"><thead><tr class="border-b text-left text-xs uppercase text-slate-500"><th class="px-3 py-2">Engagement</th><th class="px-3 py-2">Review Pending</th><th class="px-3 py-2">Weighted Progress</th><th class="px-3 py-2">SLA / Age</th><th class="px-3 py-2">Action</th></tr></thead><tbody>{% for row in engagement_review_queue %}<tr class="border-b border-slate-100"><td class="px-3 py-3"><div class="font-semibold">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.service_name }} · {{ row.financial_year }}</div></td><td class="px-3 py-3 font-semibold text-violet-700">{{ row.review_pending }}</td><td class="px-3 py-3">{{ row.progress_percent }}%</td><td class="px-3 py-3"><div class="{% if row.sla.status == 'breached' %}text-red-700{% else %}text-amber-700{% endif %}">{{ row.sla.label }}</div><div class="text-xs text-slate-500">{{ row.sla.age_days }} day(s)</div></td><td class="px-3 py-3"><a href="/manager/reviews/engagements/{{ row.subscription_id }}" class="af-btn af-btn-primary">Start / Continue Review</a></td></tr>{% else %}<tr><td colspan="5" class="px-3 py-8 text-center text-slate-500">No engagement review dependencies.</td></tr>{% endfor %}</tbody></table></section>
<div class="af-card"><div class="mb-4"><h3 class="text-lg font-semibold">Detailed Task Review Queue</h3><p class="text-sm text-slate-500">Existing task-level review workflow remains available.</p></div>{% set rows = review_queue %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div> <div class="af-card"><div class="mb-4"><h3 class="text-lg font-semibold">Detailed Task Review Queue</h3><p class="text-sm text-slate-500">Existing task-level review workflow remains available.</p></div>{% set rows = review_queue %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
</div> </div>
+109 -3
View File
@@ -1,15 +1,21 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
from app.core.http_responses import ui_access_denied 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.security.session_auth import get_current_user
from app.core.templating import templates from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles 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"]) router = APIRouter(prefix="/manager", tags=["manager-dashboard-v2-ui"])
@@ -74,3 +80,103 @@ def dashboard_tab(request: Request, tab_name: str):
) )
finally: finally:
db.close() 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()