Add advanced employee engagement workflow controls phase 4
This commit is contained in:
@@ -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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user