Add manager engagement review workspace phase 5A
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+82
@@ -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 & 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
-1
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user