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}
# 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
class EmployeeScope:
@@ -2955,8 +2964,12 @@ def list_employee_work_kanban(
for card in engagement_lookup.values():
total = card["task_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["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"))
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(
db: Session,
scope: EmployeeScope,
@@ -3199,7 +3354,9 @@ def get_employee_engagement_work_board(
category["in_progress"] += 1
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
) if category["total"] else 0
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)
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)
manual_blocker = _workflow_manual_blocker(subscription)
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]),
"summary": summary,
"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,
"active_task": selected_task,
"active_category": selected_category,
@@ -78,10 +78,10 @@
<div class="mt-4">
<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-900">{{ card.progress_percent }}%</span>
<span class="font-semibold text-slate-900">{{ card.progress_percent }}% weighted</span>
</div>
<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 class="mt-2 text-xs text-slate-500">{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed</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('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 %}
<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>
@@ -200,6 +209,15 @@
</div>
<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>
<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>
+32
View File
@@ -51,6 +51,7 @@ from app.modules.employees.service import (
save_employee_workflow_task,
pause_employee_engagement_workflow,
resume_employee_engagement_workflow,
escalate_employee_engagement_workflow,
list_employee_work_assignable_users,
list_visible_work_assignment_dashboard,
list_engagement_progress_dashboard,
@@ -2169,6 +2170,37 @@ def employee_workflow_resume(request: Request, engagement_id: int, csrf_token: s
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")
def employee_workflow_task_save(
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.core.iam.models import User
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.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]:
scope = _scope(db, request, current_user)
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)
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)
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),
"client_count": len(client_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,
}
@@ -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],
"staff_rows": staff_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(),
"wizards": _wizard_cards(),
}
@@ -1,20 +1,5 @@
<div class="space-y-6">
<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">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>
<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-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>
<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>
<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-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>
@@ -1,3 +1,5 @@
<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>
@@ -1,10 +1,17 @@
<div class="space-y-6">
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<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-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"><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"><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"><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 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">Visible Engagements</div><div class="mt-2 text-3xl font-semibold">{{ overview.engagement_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 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 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>
</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>
<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>