From dfdc74b5349b78527fc7964bcac7ad9a879873a9 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 19 Jul 2026 23:49:31 +0530 Subject: [PATCH] Add grouped employee engagement workflow workspace phase 2 --- app/modules/employees/service.py | 210 ++++++++++++++-- .../employees/work_engagement_board.html | 233 ++++++++++++------ app/modules/employees/ui.py | 74 +++++- 3 files changed, 417 insertions(+), 100 deletions(-) diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index c0a77ad..1cedcfc 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -22,7 +22,13 @@ from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeR from app.modules.clients.models import Client from app.modules.documents.models import EngagementDocument from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment -from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES +from app.modules.services.execution import ( + CLOSED_TASK_STATUSES, + TASK_PRIORITIES, + TASK_STATUSES, + apply_task_checklist_response, + recalculate_task_aqmm_status, +) EMPLOYEE_STATUS = ["active", "inactive", "relieved"] EMPLOYMENT_TYPES = ["full_time", "part_time", "article_assistant", "intern", "consultant", "contract"] @@ -2994,9 +3000,37 @@ def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engage return db.execute(stmt).scalars().unique().all() -def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> dict[str, Any]: +def _employee_task_category(task: ClientServiceTaskInstance) -> str: + value = (getattr(task, "task_category", None) or "").strip() + return value or "General Workflow" + + +def _employee_workflow_next_task(tasks: list[ClientServiceTaskInstance], current_task_id: int) -> ClientServiceTaskInstance | None: + current_index = next((index for index, row in enumerate(tasks) if row.id == current_task_id), -1) + ordered = tasks[current_index + 1 :] + tasks[: max(current_index, 0)] + return next( + (row for row in ordered if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES), + None, + ) + + +def get_employee_engagement_work_board( + db: Session, + scope: EmployeeScope, + engagement_id: int, + *, + financial_year: str | None = None, + active_task_id: int | None = None, +) -> dict[str, Any]: + """Build the employee engagement workspace grouped by task_category. + + Only tasks assigned to the logged-in employee are returned. Existing task, + document, checklist, AQMM and lock controls remain the source of truth. + """ today = date.today() - stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id) + stmt = _employee_work_task_query( + db, scope, assigned_only=True, financial_year=financial_year + ).where(ClientServiceTaskInstance.subscription_id == engagement_id) tasks = db.execute( stmt.order_by( ClientServiceTaskInstance.sequence_no.asc(), @@ -3006,18 +3040,23 @@ def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engage ) ).scalars().all() if not tasks: - raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you") + raise HTTPException( + status_code=404, + detail="Engagement work not found or not assigned to you", + ) - subscription = getattr(tasks[0], "subscription", None) - client = getattr(tasks[0], "client", None) - summary = {"total": len(tasks), "open": 0, "pending": 0, "in_progress": 0, "blocked": 0, "completed": 0, "overdue": 0, "due_today": 0} - columns = [ - {"code": "pending", "label": "Pending", "tasks": []}, - {"code": "in_progress", "label": "In Progress", "tasks": []}, - {"code": "blocked", "label": "Blocked", "tasks": []}, - {"code": "completed", "label": "Completed", "tasks": []}, - ] - column_lookup = {c["code"]: c for c in columns} + summary = { + "total": len(tasks), + "open": 0, + "pending": 0, + "in_progress": 0, + "blocked": 0, + "completed": 0, + "overdue": 0, + "due_today": 0, + } + category_lookup: dict[str, dict[str, Any]] = {} + categories: list[dict[str, Any]] = [] for task in tasks: _phase7i_task_card_enrich(task, today=today) @@ -3030,23 +3069,148 @@ def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engage summary["overdue"] += 1 if task.is_due_today: summary["due_today"] += 1 - column_code = "completed" if is_closed else status_code - if column_code not in column_lookup: - column_code = "pending" - column_lookup[column_code]["tasks"].append(task) + + category_name = _employee_task_category(task) + category = category_lookup.get(category_name) + if category is None: + category = { + "name": category_name, + "tasks": [], + "total": 0, + "completed": 0, + "blocked": 0, + "in_progress": 0, + "progress_percent": 0, + "status": "pending", + } + category_lookup[category_name] = category + categories.append(category) + category["tasks"].append(task) + category["total"] += 1 + if is_closed: + category["completed"] += 1 + elif status_code == "blocked": + category["blocked"] += 1 + elif status_code == "in_progress": + category["in_progress"] += 1 + + for category in categories: + category["progress_percent"] = round( + (category["completed"] / category["total"]) * 100 + ) if category["total"] else 0 + if category["completed"] == category["total"] and category["total"]: + category["status"] = "completed" + elif category["blocked"]: + category["status"] = "blocked" + elif category["in_progress"] or category["completed"]: + category["status"] = "in_progress" + + selected_task = None + if active_task_id is not None: + selected_task = next((row for row in tasks if row.id == active_task_id), None) + if selected_task is None: + selected_task = next( + (row for row in tasks if (row.status or "pending").strip().lower() == "in_progress"), + None, + ) + if selected_task is None: + selected_task = next( + (row for row in tasks if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES), + tasks[0], + ) + + 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 return { "engagement_id": engagement_id, - "subscription": subscription, - "client": client, - "label": _subscription_label(subscription, tasks[0]), + "subscription": getattr(tasks[0], "subscription", None), + "client": getattr(tasks[0], "client", None), + "label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]), "summary": summary, - "columns": columns, - "documents": list_employee_engagement_documents(db, scope, engagement_id, financial_year=financial_year), + "progress_percent": overall_progress, + "categories": categories, + "active_task": selected_task, + "active_category": selected_category, + "next_task": next_task, + "documents": list_employee_engagement_documents( + db, scope, engagement_id, financial_year=financial_year + ), "today": today, } +def save_employee_workflow_task( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + status: str, + remarks: str, + checklist_response: str, + checklist_text_response: str, + checklist_number_response: str, + checklist_date_response: str, + checklist_remarks: str, + actor_user_id: int, + financial_year: str | None = None, +) -> tuple[ClientServiceTaskInstance, ClientServiceTaskInstance | None]: + """Save one assigned workflow task and return its next open task. + + The operation is atomic: checklist validation, task status, AQMM status and + timestamps are committed together. Locked tasks and locked engagements are + never modified. + """ + stmt = _employee_work_task_query( + db, scope, assigned_only=True, financial_year=financial_year + ).where(ClientServiceTaskInstance.id == task_id) + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found or not assigned to you") + if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): + raise ValueError("Locked task cannot be changed.") + + normalised_status = (status or "pending").strip().lower() + if normalised_status not in {"pending", "in_progress", "blocked", "completed"}: + raise ValueError("Invalid task status.") + + apply_task_checklist_response( + db, + task, + checklist_response=checklist_response, + checklist_text_response=checklist_text_response, + checklist_number_response=checklist_number_response, + checklist_date_response=checklist_date_response, + checklist_remarks=checklist_remarks, + requested_status=normalised_status, + ) + + task.status = normalised_status + if normalised_status == "completed": + task.completed_at_utc = datetime.now(timezone.utc) + else: + task.completed_at_utc = None + if normalised_status == "in_progress" and not getattr(task, "started_at_utc", None): + task.started_at_utc = datetime.now(timezone.utc) + if remarks.strip(): + task.remarks = remarks.strip() + task.updated_by_user_id = actor_user_id + recalculate_task_aqmm_status(db, task) + db.add(task) + db.commit() + db.refresh(task) + + engagement_tasks = db.execute( + _employee_work_task_query( + db, scope, assigned_only=True, financial_year=financial_year + ) + .where(ClientServiceTaskInstance.subscription_id == task.subscription_id) + .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) + ).scalars().all() + return task, _employee_workflow_next_task(engagement_tasks, task.id) + + def list_employee_work_assignable_users(db: Session, scope: EmployeeScope) -> list[User]: """Users that can be assigned engagement/service tasks in the active employee scope.""" diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index e7b67c1..66e5949 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -2,6 +2,7 @@ {% block content %}
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +

{{ board.label }}

@@ -12,106 +13,188 @@
-
-
Total Tasks
{{ board.summary.total }}
-
Open
{{ board.summary.open }}
-
In Progress
{{ board.summary.in_progress }}
+ {% if request.query_params.get('saved') %} +
Task response saved successfully.
+ {% endif %} + {% if request.query_params.get('workflow_error') %} +
The task could not be saved. Complete the required response, remarks and evidence, and confirm that the task is not locked.
+ {% endif %} + +
+
+
Overall Progress
+
{{ board.progress_percent }}%
+
+
+
Total
{{ board.summary.total }}
+
In Progress
{{ board.summary.in_progress }}
Blocked
{{ board.summary.blocked }}
-
Overdue
{{ board.summary.overdue }}
Completed
{{ board.summary.completed }}
-
-
- {% for column in board.columns %} -
-
-

{{ column.label }}

- {{ column.tasks|length }} +
+
- {% endfor %} -
+
+
+ + +
+
- + +
{% endblock %} diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index f00e4a7..122a611 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -48,6 +48,7 @@ from app.modules.employees.service import ( list_employee_work_kanban, get_employee_engagement_work_board, start_employee_engagement_workflow, + save_employee_workflow_task, list_employee_work_assignable_users, list_visible_work_assignment_dashboard, list_engagement_progress_dashboard, @@ -2091,7 +2092,7 @@ def employee_my_work_engagement_start( @portal_router.get("/work/engagements/{engagement_id}") -def employee_my_work_engagement_board(request: Request, engagement_id: int): +def employee_my_work_engagement_board(request: Request, engagement_id: int, task_id: int | None = None): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2106,7 +2107,13 @@ def employee_my_work_engagement_board(request: Request, engagement_id: int): scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) employee = get_employee_for_user(db, current_user) financial_year = _active_financial_year(request) - board = get_employee_engagement_work_board(db, scope, engagement_id, financial_year=financial_year) + board = get_employee_engagement_work_board( + db, + scope, + engagement_id, + financial_year=financial_year, + active_task_id=task_id, + ) return _render( request, "modules/employees/templates/employees/work_engagement_board.html", @@ -2122,6 +2129,69 @@ def employee_my_work_engagement_board(request: Request, engagement_id: int): db.close() +@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save") +def employee_workflow_task_save( + request: Request, + engagement_id: int, + task_id: int, + status: str = Form("pending"), + remarks: str = Form(""), + checklist_response: str = Form(""), + checklist_text_response: str = Form(""), + checklist_number_response: str = Form(""), + checklist_date_response: str = Form(""), + checklist_remarks: str = Form(""), + workflow_action: str = Form("save"), + csrf_token: str = Form(...), +): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + try: + require_permission(db, current_user, "employees.work.view_self") + except Exception: + return _redirect_denied() + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) + + tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id + branch_id = request.session.get("active_branch_id") + scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) + try: + saved_task, next_task = save_employee_workflow_task( + db, + scope, + task_id, + status=status, + remarks=remarks, + checklist_response=checklist_response, + checklist_text_response=checklist_text_response, + checklist_number_response=checklist_number_response, + checklist_date_response=checklist_date_response, + checklist_remarks=checklist_remarks, + actor_user_id=current_user.id, + financial_year=_active_financial_year(request), + ) + except ValueError: + db.rollback() + return RedirectResponse( + url=f"/employee/work/engagements/{engagement_id}?task_id={task_id}&workflow_error=1", + status_code=303, + ) + + target_task = next_task if workflow_action == "save_next" and next_task else saved_task + return RedirectResponse( + url=f"/employee/work/engagements/{engagement_id}?task_id={target_task.id}&saved=1", + status_code=303, + ) + finally: + db.close() + + @router.get("/work/tasks/{task_id}/communication") def employee_work_task_communication( request: Request,