Add advanced employee engagement workflow controls phase 4

This commit is contained in:
A R R R Associates
2026-07-20 00:47:34 +05:30
parent 031c278e8f
commit fe23b2b253
8 changed files with 330 additions and 32 deletions
+165 -3
View File
@@ -74,6 +74,15 @@ WORKFLOW_HOLD_REASONS = {
TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES} TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES}
# Phase 4 derived controls deliberately reuse existing task, engagement, review and
# alert records. No duplicate workflow or capacity tables are introduced.
WORKFLOW_ESCALATION_LEVELS = {
"manager": "Manager",
"partner": "Engagement Partner",
"review_partner": "Review Partner",
}
@dataclass @dataclass
class EmployeeScope: class EmployeeScope:
@@ -2955,8 +2964,12 @@ def list_employee_work_kanban(
for card in engagement_lookup.values(): for card in engagement_lookup.values():
total = card["task_count"] total = card["task_count"]
completed = card["completed_count"] completed = card["completed_count"]
card["progress_percent"] = int(round((completed * 100) / total)) if total else 0 weighted = _weighted_progress(card.get("tasks", []))
card.update(weighted)
card["simple_progress_percent"] = int(round((completed * 100) / total)) if total else 0
card["open_count"] = max(total - completed, 0) card["open_count"] = max(total - completed, 0)
card["engagement_team"] = _engagement_team(card.get("subscription"))
card["sla"] = _engagement_sla(card.get("subscription"), card.get("tasks", []))
manual_blocker = _workflow_manual_blocker(card.get("subscription")) manual_blocker = _workflow_manual_blocker(card.get("subscription"))
automatic_blocker = _workflow_automatic_blocker(card.get("subscription"), card.get("tasks", [])) automatic_blocker = _workflow_automatic_blocker(card.get("subscription"), card.get("tasks", []))
@@ -3118,6 +3131,148 @@ def _employee_workflow_next_task(tasks: list[ClientServiceTaskInstance], current
) )
def _workflow_task_weight(task: ClientServiceTaskInstance) -> int:
"""Stable derived progress weight without changing the task schema.
Ordinary execution tasks count as 1. Review and AQMM/final-release tasks carry
higher weight so completion cannot appear artificially high while significant
review gates remain open.
"""
weight = 1
if bool(getattr(task, "is_aqmm_task", False)):
weight += 2
if bool(getattr(task, "aqmm_blocks_final_release", False)):
weight += 2
if any(bool(getattr(task, name, False)) for name in (
"aqmm_manager_review_required",
"aqmm_partner_review_required",
"aqmm_review_partner_required",
)):
weight += 1
if (getattr(task, "default_role_name", "") or "").strip().lower() in {"manager", "partner", "review partner"}:
weight += 1
return min(weight, 7)
def _weighted_progress(tasks: list[ClientServiceTaskInstance]) -> dict[str, int]:
total_weight = sum(_workflow_task_weight(task) for task in tasks)
completed_weight = sum(
_workflow_task_weight(task)
for task in tasks
if (getattr(task, "status", None) or "pending").strip().lower() in CLOSED_TASK_STATUSES
)
percent = int(round((completed_weight * 100) / total_weight)) if total_weight else 0
return {
"total_weight": total_weight,
"completed_weight": completed_weight,
"progress_percent": percent,
}
def _engagement_team(subscription: ClientServiceSubscription | None) -> list[dict[str, Any]]:
if subscription is None:
return []
team = []
for role, rel_name, id_name in (
("Engagement Partner", "assigned_partner", "assigned_partner_user_id"),
("Manager", "assigned_manager", "assigned_manager_user_id"),
("Primary Staff", "assigned_staff", "assigned_staff_user_id"),
("Review Partner", "review_partner", "review_partner_user_id"),
):
user = getattr(subscription, rel_name, None)
user_id = getattr(subscription, id_name, None)
if user_id:
team.append({
"role": role,
"user_id": user_id,
"name": getattr(user, "full_name", None) or getattr(user, "email", None) or f"User #{user_id}",
"email": getattr(user, "email", None) or "",
})
return team
def _engagement_sla(subscription: ClientServiceSubscription | None, tasks: list[ClientServiceTaskInstance]) -> dict[str, Any]:
today = date.today()
due_date = None
if subscription is not None:
due_date = getattr(subscription, "current_due_date", None) or getattr(subscription, "original_due_date", None) or getattr(subscription, "end_date", None)
if due_date is None:
dates = [getattr(task, "internal_target_date", None) for task in tasks if getattr(task, "internal_target_date", None)]
due_date = max(dates) if dates else None
started_dates = [getattr(task, "started_at_utc", None) for task in tasks if getattr(task, "started_at_utc", None)]
created_at = getattr(subscription, "created_at_utc", None) if subscription is not None else None
anchor = min(started_dates) if started_dates else created_at
age_days = max((datetime.now(timezone.utc) - anchor).days, 0) if anchor else 0
days_remaining = (due_date - today).days if due_date else None
if due_date and days_remaining < 0:
status = "breached"
label = f"Overdue by {abs(days_remaining)} day(s)"
elif due_date and days_remaining <= 3:
status = "critical"
label = f"Due in {days_remaining} day(s)"
elif due_date and days_remaining <= 7:
status = "warning"
label = f"Due in {days_remaining} day(s)"
elif due_date:
status = "on_track"
label = f"{days_remaining} day(s) remaining"
else:
status = "not_set"
label = "No SLA due date"
return {
"due_date": due_date,
"days_remaining": days_remaining,
"age_days": age_days,
"status": status,
"label": label,
}
def escalate_employee_engagement_workflow(
db: Session,
scope: EmployeeScope,
engagement_id: int,
*,
level: str,
message: str,
actor_user_id: int,
financial_year: str | None = None,
) -> int:
tasks = _employee_engagement_tasks(db, scope, engagement_id, financial_year=financial_year)
if not tasks:
raise ValueError("Engagement was not found or is not assigned to you.")
subscription = getattr(tasks[0], "subscription", None)
if subscription is None:
raise ValueError("Engagement subscription was not found.")
code = (level or "manager").strip().lower()
target_id = {
"manager": getattr(subscription, "assigned_manager_user_id", None),
"partner": getattr(subscription, "assigned_partner_user_id", None),
"review_partner": getattr(subscription, "review_partner_user_id", None),
}.get(code)
if not target_id:
raise ValueError(f"{WORKFLOW_ESCALATION_LEVELS.get(code, 'Selected reviewer')} is not assigned to this engagement.")
clean_message = (message or "").strip()[:1000]
if not clean_message:
raise ValueError("Escalation details are required.")
create_alert(
db,
user_id=int(target_id),
title=f"Workflow escalation: {_subscription_label(subscription, tasks[0])}",
message=clean_message,
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
role_context=code,
alert_type="escalation",
priority="high",
target_url=f"/employee/work/engagements/{engagement_id}",
created_by_user_id=actor_user_id,
commit=False,
)
db.commit()
return int(target_id)
def get_employee_engagement_work_board( def get_employee_engagement_work_board(
db: Session, db: Session,
scope: EmployeeScope, scope: EmployeeScope,
@@ -3199,7 +3354,9 @@ def get_employee_engagement_work_board(
category["in_progress"] += 1 category["in_progress"] += 1
for category in categories: for category in categories:
category["progress_percent"] = round( weighted_category = _weighted_progress(category["tasks"])
category.update(weighted_category)
category["simple_progress_percent"] = round(
(category["completed"] / category["total"]) * 100 (category["completed"] / category["total"]) * 100
) if category["total"] else 0 ) if category["total"] else 0
if category["completed"] == category["total"] and category["total"]: if category["completed"] == category["total"] and category["total"]:
@@ -3225,7 +3382,8 @@ def get_employee_engagement_work_board(
selected_category = _employee_task_category(selected_task) selected_category = _employee_task_category(selected_task)
next_task = _employee_workflow_next_task(tasks, selected_task.id) next_task = _employee_workflow_next_task(tasks, selected_task.id)
overall_progress = round((summary["completed"] / summary["total"]) * 100) if summary["total"] else 0 weighted_overall = _weighted_progress(tasks)
overall_progress = weighted_overall["progress_percent"]
subscription = getattr(tasks[0], "subscription", None) subscription = getattr(tasks[0], "subscription", None)
manual_blocker = _workflow_manual_blocker(subscription) manual_blocker = _workflow_manual_blocker(subscription)
automatic_blocker = _workflow_automatic_blocker(subscription, tasks) automatic_blocker = _workflow_automatic_blocker(subscription, tasks)
@@ -3241,6 +3399,10 @@ def get_employee_engagement_work_board(
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]), "label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
"summary": summary, "summary": summary,
"progress_percent": overall_progress, "progress_percent": overall_progress,
"weighted_progress": weighted_overall,
"engagement_team": _engagement_team(subscription),
"sla": _engagement_sla(subscription, tasks),
"escalation_levels": WORKFLOW_ESCALATION_LEVELS,
"categories": categories, "categories": categories,
"active_task": selected_task, "active_task": selected_task,
"active_category": selected_category, "active_category": selected_category,
@@ -78,10 +78,10 @@
<div class="mt-4"> <div class="mt-4">
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="font-semibold text-slate-600">My progress</span> <span class="font-semibold text-slate-600">My progress</span>
<span class="font-semibold text-slate-900">{{ card.progress_percent }}%</span> <span class="font-semibold text-slate-900">{{ card.progress_percent }}% weighted</span>
</div> </div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200"> <div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-brand-600" style="width: {{ card.progress_percent }}%"></div> <div class="h-full rounded-full bg-brand-600" style="width: {{ card.progress_percent }}% weighted"></div>
</div> </div>
<div class="mt-2 text-xs text-slate-500">{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed</div> <div class="mt-2 text-xs text-slate-500">{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed</div>
</div> </div>
@@ -30,6 +30,15 @@
{% if request.query_params.get('resumed') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Engagement workflow resumed.</div>{% endif %} {% if request.query_params.get('resumed') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Engagement workflow resumed.</div>{% endif %}
{% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The workflow status could not be changed. Confirm the reason and that the engagement is not locked.</div>{% endif %} {% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The workflow status could not be changed. Confirm the reason and that the engagement is not locked.</div>{% endif %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Weighted Progress</div><div class="mt-2 text-3xl font-semibold">{{ board.progress_percent }}%</div><div class="mt-1 text-xs text-slate-500">{{ board.weighted_progress.completed_weight }} of {{ board.weighted_progress.total_weight }} weighted points</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">SLA &amp; Ageing</div><div class="mt-2 text-lg font-semibold {% if board.sla.status == 'breached' %}text-red-700{% elif board.sla.status in ['critical','warning'] %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ board.sla.label }}</div><div class="mt-1 text-xs text-slate-500">Age {{ board.sla.age_days }} day(s){% if board.sla.due_date %} · Due {{ board.sla.due_date }}{% endif %}</div></div>
<div class="af-metric-card md:col-span-2"><div class="text-xs font-semibold uppercase text-slate-500">Engagement Team</div><div class="mt-3 flex flex-wrap gap-2">{% for member in board.engagement_team %}<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs"><strong>{{ member.role }}:</strong> {{ member.name }}</span>{% else %}<span class="text-sm text-slate-500">No engagement team assigned.</span>{% endfor %}</div></div>
</section>
{% if request.query_params.get('escalated') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Escalation alert sent successfully.</div>{% endif %}
{% if request.query_params.get('escalation_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Escalation could not be sent. Confirm the selected reviewer is assigned and enter details.</div>{% endif %}
{% if board.workflow_blocker %} {% if board.workflow_blocker %}
<div class="rounded-2xl border {% if board.workflow_blocker.type == 'manual' %}border-amber-300 bg-amber-50{% else %}border-violet-300 bg-violet-50{% endif %} p-4"> <div class="rounded-2xl border {% if board.workflow_blocker.type == 'manual' %}border-amber-300 bg-amber-50{% else %}border-violet-300 bg-violet-50{% endif %} p-4">
<div class="font-semibold text-slate-900">{{ board.workflow_blocker.label }}</div> <div class="font-semibold text-slate-900">{{ board.workflow_blocker.label }}</div>
@@ -200,6 +209,15 @@
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-5"> <div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-5">
<details class="w-full rounded-xl border border-amber-200 bg-amber-50 p-3 text-left">
<summary class="cursor-pointer text-sm font-semibold text-amber-900">Escalate dependency or review delay</summary>
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/escalate" class="mt-3 grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="level" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" required>{% for code, label in board.escalation_levels.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select>
<textarea name="message" rows="3" maxlength="1000" required class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" placeholder="Explain the dependency, ageing or review delay"></textarea>
<button type="submit" class="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-semibold text-amber-900">Send Escalation</button>
</form>
</details>
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a> <a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button type="submit" name="workflow_action" value="save" class="rounded-xl border border-brand-300 bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save</button> <button type="submit" name="workflow_action" value="save" class="rounded-xl border border-brand-300 bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save</button>
+32
View File
@@ -51,6 +51,7 @@ from app.modules.employees.service import (
save_employee_workflow_task, save_employee_workflow_task,
pause_employee_engagement_workflow, pause_employee_engagement_workflow,
resume_employee_engagement_workflow, resume_employee_engagement_workflow,
escalate_employee_engagement_workflow,
list_employee_work_assignable_users, list_employee_work_assignable_users,
list_visible_work_assignment_dashboard, list_visible_work_assignment_dashboard,
list_engagement_progress_dashboard, list_engagement_progress_dashboard,
@@ -2169,6 +2170,37 @@ def employee_workflow_resume(request: Request, engagement_id: int, csrf_token: s
finally: db.close() finally: db.close()
@portal_router.post("/work/engagements/{engagement_id}/escalate")
def employee_workflow_escalate(
request: Request,
engagement_id: int,
level: str = Form("manager"),
message: str = Form(""),
csrf_token: str = Form(""),
):
if not validate_csrf(request, csrf_token):
return _csrf_rejected(request)
with CommonSessionLocal() as db:
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
scope = build_employee_scope(
db, current_user,
tenant_id=request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None),
branch_id=request.session.get("active_branch_id"),
)
try:
escalate_employee_engagement_workflow(
db, scope, engagement_id, level=level, message=message,
actor_user_id=current_user.id,
financial_year=request.session.get("active_financial_year"),
)
except ValueError:
db.rollback()
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?escalation_error=1", status_code=303)
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?escalated=1", status_code=303)
@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save") @portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save")
def employee_workflow_task_save( def employee_workflow_task_save(
request: Request, request: Request,
+93 -1
View File
@@ -9,7 +9,13 @@ from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client from app.modules.clients.models import Client
from app.modules.core.iam.models import User from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole
from app.modules.employees.service import build_employee_scope, list_employee_work_assignable_users from app.modules.employees.service import (
build_employee_scope,
list_employee_work_assignable_users,
_engagement_sla,
_engagement_team,
_weighted_progress,
)
from app.modules.services.execution import CLOSED_TASK_STATUSES from app.modules.services.execution import CLOSED_TASK_STATUSES
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment
@@ -288,6 +294,77 @@ def _wizard_cards() -> list[dict[str, str]]:
] ]
def _advanced_engagement_rows(tasks: list[ClientServiceTaskInstance]) -> list[dict[str, Any]]:
grouped: dict[int, list[ClientServiceTaskInstance]] = {}
for task in tasks:
grouped.setdefault(int(task.subscription_id), []).append(task)
rows: list[dict[str, Any]] = []
for subscription_id, group in grouped.items():
first = group[0]
subscription = getattr(first, "subscription", None)
weighted = _weighted_progress(group)
sla = _engagement_sla(subscription, group)
team = _engagement_team(subscription)
status_values = {(getattr(t, "status", None) or "pending").strip().lower() for t in group}
completed = sum(1 for t in group if (getattr(t, "status", None) or "pending").strip().lower() in CLOSED_TASK_STATUSES)
review_pending = sum(1 for t in group if (getattr(t, "manager_review_status", None) or "") == "pending" or (getattr(t, "partner_review_status", None) or "") == "pending" or (getattr(t, "review_partner_review_status", None) or "") == "pending")
if completed == len(group) and group:
status = "completed"
elif "blocked" in status_values or getattr(subscription, "workflow_pause_reason", None):
status = "blocked"
elif "in_progress" in status_values or completed:
status = "in_progress"
else:
status = "pending"
rows.append({
"subscription_id": 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 "-",
"status": status,
"task_count": len(group),
"completed_count": completed,
"review_pending": review_pending,
"progress_percent": weighted["progress_percent"],
"completed_weight": weighted["completed_weight"],
"total_weight": weighted["total_weight"],
"sla": sla,
"team": team,
"manager_name": next((m["name"] for m in team if m["role"] == "Manager"), "Unassigned"),
"staff_name": next((m["name"] for m in team if m["role"] == "Primary Staff"), "Unassigned"),
"partner_name": next((m["name"] for m in team if m["role"] == "Engagement Partner"), "Unassigned"),
"href": f"/employee/work/engagements/{subscription_id}",
})
rows.sort(key=lambda r: (r["sla"]["status"] == "breached", r["review_pending"], -r["progress_percent"]), reverse=True)
return rows
def _capacity_rows(tasks: list[ClientServiceTaskInstance]) -> list[dict[str, Any]]:
by_user: dict[str, dict[str, Any]] = {}
for task in tasks:
user = getattr(task, "assigned_to", None)
name = getattr(user, "full_name", None) or getattr(user, "email", None) or "Unassigned"
row = by_user.setdefault(name, {"name": name, "open_weight": 0, "open_tasks": 0, "overdue": 0, "review": 0, "engagement_ids": set()})
status = (getattr(task, "status", None) or "pending").strip().lower()
if status not in CLOSED_TASK_STATUSES:
row["open_tasks"] += 1
row["open_weight"] += _weighted_progress([task])["total_weight"]
row["engagement_ids"].add(int(task.subscription_id))
if getattr(task, "internal_target_date", None) and task.internal_target_date < date.today():
row["overdue"] += 1
if status in REVIEW_STATUSES:
row["review"] += 1
out=[]
for row in by_user.values():
score = row["open_weight"] + row["review"] * 2 + row["overdue"] * 3
row["engagement_count"] = len(row.pop("engagement_ids"))
row["capacity_score"] = score
row["load_status"] = "Heavy" if score >= 55 else ("Balanced" if score >= 18 else "Light")
out.append(row)
out.sort(key=lambda r: r["capacity_score"], reverse=True)
return out
def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]: def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
scope = _scope(db, request, current_user) scope = _scope(db, request, current_user)
fy = _active_financial_year(request) fy = _active_financial_year(request)
@@ -306,6 +383,11 @@ def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[
staff_rows = _staff_rows(db, scope, task_rows) staff_rows = _staff_rows(db, scope, task_rows)
client_rows = _client_rows(db, scope, task_rows) client_rows = _client_rows(db, scope, task_rows)
advanced_engagements = _advanced_engagement_rows(tasks)
capacity_rows = _capacity_rows(tasks)
sla_breached = [row for row in advanced_engagements if row["sla"]["status"] == "breached"]
sla_warning = [row for row in advanced_engagements if row["sla"]["status"] in {"critical", "warning"}]
engagement_review_queue = [row for row in advanced_engagements if row["review_pending"]]
tenant = getattr(scope, "tenant", None) tenant = getattr(scope, "tenant", None)
branch = getattr(scope, "branch", None) branch = getattr(scope, "branch", None)
@@ -347,6 +429,11 @@ def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[
"staff_count": len(staff_rows), "staff_count": len(staff_rows),
"client_count": len(client_rows), "client_count": len(client_rows),
"escalation_count": len(escalation_rows), "escalation_count": len(escalation_rows),
"engagement_count": len(advanced_engagements),
"sla_breached_count": len(sla_breached),
"sla_warning_count": len(sla_warning),
"engagement_review_count": len(engagement_review_queue),
"heavy_capacity_count": len([row for row in capacity_rows if row["load_status"] == "Heavy"]),
"today": today, "today": today,
} }
@@ -368,6 +455,11 @@ def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[
"service_summary": sorted([{"label": k, "count": v} for k, v in service_summary.items()], key=lambda x: x["count"], reverse=True)[:10], "service_summary": sorted([{"label": k, "count": v} for k, v in service_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
"staff_rows": staff_rows, "staff_rows": staff_rows,
"clients": client_rows, "clients": client_rows,
"advanced_engagements": advanced_engagements,
"capacity_rows": capacity_rows,
"sla_breached": sla_breached,
"sla_warning": sla_warning,
"engagement_review_queue": engagement_review_queue,
"reports": _report_cards(), "reports": _report_cards(),
"wizards": _wizard_cards(), "wizards": _wizard_cards(),
} }
@@ -1,20 +1,5 @@
<div class="space-y-6"> <div class="space-y-6">
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"><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><div class="af-metric-card border-amber-200 bg-amber-50"><div class="text-xs font-semibold uppercase text-amber-700">SLA Warning</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ overview.sla_warning_count }}</div></div><div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Task Escalations</div><div class="mt-2 text-3xl font-semibold">{{ overview.escalation_count }}</div></div><div class="af-metric-card border-violet-200 bg-violet-50"><div class="text-xs font-semibold uppercase text-violet-700">Review Dependencies</div><div class="mt-2 text-3xl font-semibold text-violet-700">{{ overview.engagement_review_count }}</div></div></section>
<div class="af-metric-card border-red-200 bg-red-50"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ overview.overdue_count }}</div><div class="mt-1 text-xs text-red-700">Ageing risk</div></div> <section class="grid gap-6 xl:grid-cols-2"><div class="af-card overflow-x-auto"><h3 class="text-lg font-semibold">SLA Breaches</h3><p class="mb-4 text-sm text-slate-500">Engagement-level statutory/internal due-date breaches.</p><table class="min-w-full text-sm"><tbody>{% for row in sla_breached %}<tr class="border-b"><td class="px-3 py-3"><a href="{{ row.href }}" class="font-semibold text-brand-700">{{ row.client_name }} — {{ row.service_name }}</a><div class="text-xs text-slate-500">{{ row.financial_year }} · {{ row.progress_percent }}% complete</div></td><td class="px-3 py-3 text-right"><div class="font-semibold text-red-700">{{ row.sla.label }}</div><div class="text-xs text-slate-500">Age {{ row.sla.age_days }} day(s)</div></td></tr>{% else %}<tr><td class="px-3 py-8 text-center text-slate-500">No SLA breaches.</td></tr>{% endfor %}</tbody></table></div><div class="af-card overflow-x-auto"><h3 class="text-lg font-semibold">SLA Warning Window</h3><p class="mb-4 text-sm text-slate-500">Due within seven days.</p><table class="min-w-full text-sm"><tbody>{% for row in sla_warning %}<tr class="border-b"><td class="px-3 py-3"><a href="{{ row.href }}" class="font-semibold text-brand-700">{{ row.client_name }} — {{ row.service_name }}</a><div class="text-xs text-slate-500">{{ row.financial_year }} · {{ row.progress_percent }}% complete</div></td><td class="px-3 py-3 text-right font-semibold text-amber-700">{{ row.sla.label }}</td></tr>{% else %}<tr><td class="px-3 py-8 text-center text-slate-500">No engagements in warning window.</td></tr>{% endfor %}</tbody></table></div></section>
<div class="af-metric-card border-orange-200 bg-orange-50"><div class="text-xs font-semibold uppercase text-orange-700">Unassigned</div><div class="mt-2 text-3xl font-semibold text-orange-700">{{ overview.unassigned_count }}</div><div class="mt-1 text-xs text-orange-700">Manager action</div></div> <div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold">Existing Task Escalation Register</h3><p class="text-sm text-slate-500">Unassigned, overdue and client-pending tasks remain visible.</p></div><a href="/alerts" class="af-btn af-btn-primary">Open Alerts</a></div>{% set rows = escalations %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
<div class="af-metric-card border-amber-200 bg-amber-50"><div class="text-xs font-semibold uppercase text-amber-700">Client Pending</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ overview.client_pending_count }}</div><div class="mt-1 text-xs text-amber-700">Follow-up needed</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Escalation Items</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ overview.escalation_count }}</div><div class="mt-1 text-xs text-slate-500">Combined attention list</div></div>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<div class="af-card">
<div class="mb-4 flex items-center justify-between gap-3"><div><h3 class="text-lg font-semibold text-slate-900">Escalation Register</h3><p class="text-sm text-slate-500">Unassigned, overdue and client-pending tasks sorted by urgency.</p></div><a href="/alerts" class="af-btn af-btn-primary">Open Alerts</a></div>
{% set rows = escalations %}
{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}
</div>
<aside class="space-y-4">
<div class="af-card"><h3 class="font-semibold text-slate-900">Ageing Buckets</h3><div class="mt-4 space-y-2 text-sm">{% for item in age_buckets %}<div class="flex justify-between rounded-xl bg-slate-50 px-3 py-2"><span>{{ item.label }}</span><span class="font-semibold">{{ item.count }}</span></div>{% endfor %}</div></div>
<div class="af-card"><h3 class="font-semibold text-slate-900">Escalation Actions</h3><div class="mt-4 grid gap-2"><a href="/manager/work" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Assign / Reassign Work</a><a href="/manager/dashboard?tab=client-pending" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Client Pending Follow-up</a><a href="/employees/progress" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Review Progress</a></div></div>
</aside>
</section>
</div> </div>
@@ -1,3 +1,5 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Manager Review Queue</h3><p class="text-sm text-slate-500">Completed or review-ready work waiting for manager action.</p></div><a href="/employees/progress" class="af-btn af-btn-primary">Progress</a></div>{% set rows = review_queue %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div> <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>
<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,10 +1,17 @@
<div class="space-y-6"> <div class="space-y-6">
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5"> <section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Total Visible</div><div class="mt-2 text-3xl font-semibold">{{ overview.total_tasks }}</div></div> <div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Visible Engagements</div><div class="mt-2 text-3xl font-semibold">{{ overview.engagement_count }}</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-orange-700">Unassigned</div><div class="mt-2 text-3xl font-semibold text-orange-700">{{ overview.unassigned_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>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-blue-700">In Progress</div><div class="mt-2 text-3xl font-semibold text-blue-700">{{ overview.in_progress_count }}</div></div> <div class="af-metric-card border-amber-200 bg-amber-50"><div class="text-xs font-semibold uppercase text-amber-700">SLA Warning</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ overview.sla_warning_count }}</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ overview.overdue_count }}</div></div> <div class="af-metric-card border-violet-200 bg-violet-50"><div class="text-xs font-semibold uppercase text-violet-700">Heavy Capacity</div><div class="mt-2 text-3xl font-semibold text-violet-700">{{ overview.heavy_capacity_count }}</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-amber-700">Due Today</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ overview.due_today_count }}</div></div> </section>
<section class="af-card overflow-x-auto">
<div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Engagement Capacity Plan</h3><p class="text-sm text-slate-500">Derived from open task weight, review load and overdue work. Existing assignments remain unchanged.</p></div><a href="/manager/work" class="af-btn af-btn-primary">Assign / Reassign</a></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">Team Member</th><th class="px-3 py-2">Engagements</th><th class="px-3 py-2">Open Tasks</th><th class="px-3 py-2">Weighted Load</th><th class="px-3 py-2">Review</th><th class="px-3 py-2">Overdue</th><th class="px-3 py-2">Capacity</th></tr></thead><tbody>{% for row in capacity_rows %}<tr class="border-b border-slate-100"><td class="px-3 py-3 font-semibold">{{ row.name }}</td><td class="px-3 py-3">{{ row.engagement_count }}</td><td class="px-3 py-3">{{ row.open_tasks }}</td><td class="px-3 py-3">{{ row.open_weight }}</td><td class="px-3 py-3">{{ row.review }}</td><td class="px-3 py-3">{{ row.overdue }}</td><td class="px-3 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.load_status == 'Heavy' %}bg-red-100 text-red-700{% elif row.load_status == 'Balanced' %}bg-emerald-100 text-emerald-700{% else %}bg-blue-100 text-blue-700{% endif %}">{{ row.load_status }}</span></td></tr>{% else %}<tr><td colspan="7" class="px-3 py-8 text-center text-slate-500">No visible workload.</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 Team &amp; Weighted Progress</h3><p class="text-sm text-slate-500">One row per engagement using existing Partner, Manager, Staff and Review Partner assignments.</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">Team</th><th class="px-3 py-2">Progress</th><th class="px-3 py-2">SLA</th><th class="px-3 py-2">Status</th></tr></thead><tbody>{% for row in advanced_engagements %}<tr class="border-b border-slate-100"><td class="px-3 py-3"><a class="font-semibold text-brand-700" href="{{ row.href }}">{{ row.client_name }} — {{ row.service_name }}</a><div class="text-xs text-slate-500">{{ row.financial_year }}</div></td><td class="px-3 py-3 text-xs"><div>Staff: {{ row.staff_name }}</div><div>Manager: {{ row.manager_name }}</div><div>Partner: {{ row.partner_name }}</div></td><td class="px-3 py-3"><div class="font-semibold">{{ row.progress_percent }}%</div><div class="mt-1 h-1.5 w-32 overflow-hidden rounded-full bg-slate-100"><div class="h-full bg-brand-600" style="width: {{ row.progress_percent }}%"></div></div><div class="mt-1 text-xs text-slate-500">{{ row.completed_weight }}/{{ row.total_weight }} weighted</div></td><td class="px-3 py-3"><span class="text-xs font-semibold {% if row.sla.status == 'breached' %}text-red-700{% elif row.sla.status in ['critical','warning'] %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ row.sla.label }}</span><div class="text-xs text-slate-500">Age {{ row.sla.age_days }} day(s)</div></td><td class="px-3 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ row.status.replace('_',' ').title() }}</span></td></tr>{% endfor %}</tbody></table>
</section> </section>
<div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Team Work Board Summary</h3><p class="text-sm text-slate-500">Top 100 visible tasks. Use detailed board for assignment changes.</p></div><a href="/manager/work" class="af-btn af-btn-primary">Detailed Board</a></div>{% set rows = team_work %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
</div> </div>