From e1b5409bdf84ad071a911a41f41260269533148f Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sat, 8 Aug 2026 12:28:55 +0530 Subject: [PATCH] Implement I1 simplified engagement checklist --- app/modules/employees/service.py | 230 ++++++++- .../employees/work_engagement_board.html | 436 ++++++++++-------- app/modules/employees/ui.py | 82 +++- 3 files changed, 518 insertions(+), 230 deletions(-) diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index 05c70e9..c941dd8 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -31,6 +31,7 @@ from app.modules.services.execution import ( TASK_STATUSES, apply_task_checklist_response, recalculate_task_aqmm_status, + submit_task_for_review, ) EMPLOYEE_STATUS = ["active", "inactive", "relieved"] @@ -2611,6 +2612,7 @@ def list_employee_work_dashboard( selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.template), selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), ) .where( @@ -3450,6 +3452,112 @@ def _employee_task_category(task: ClientServiceTaskInstance) -> str: return value or "General Workflow" + +def _i1_required_review_levels(task: ClientServiceTaskInstance) -> list[str]: + """Return configured review levels for I1 using existing task-template controls.""" + levels: list[str] = [] + if getattr(task, "aqmm_manager_review_required", False): + levels.append("manager") + if getattr(task, "aqmm_partner_review_required", False): + levels.append("partner") + if getattr(task, "aqmm_review_partner_required", False): + levels.append("review_partner") + + # Preserve the pre-existing generic requires_review flag. Where no explicit + # reviewer flag was configured, route to Manager when available, otherwise + # Engagement Partner. No new review table/state is introduced. + template = getattr(task, "template", None) + if not levels and getattr(template, "requires_review", False): + subscription = getattr(task, "subscription", None) + if getattr(subscription, "assigned_manager_user_id", None): + levels.append("manager") + elif getattr(subscription, "assigned_partner_user_id", None): + levels.append("partner") + return levels + + +def _i1_review_level_pending(task: ClientServiceTaskInstance, level: str) -> bool: + if level == "manager": + return (getattr(task, "manager_review_status", None) or "not_required") != "reviewed" + if level == "partner": + return (getattr(task, "partner_review_status", None) or "not_required") != "reviewed" + if level == "review_partner": + return (getattr(task, "review_partner_review_status", None) or "not_required") != "reviewed" + return False + + +def _i1_review_label(task: ClientServiceTaskInstance) -> str | None: + levels = _i1_required_review_levels(task) + if not levels: + return None + if getattr(task, "rework_status", "none") == "open": + return "Returned for correction" + pending = [level for level in levels if _i1_review_level_pending(task, level)] + if not pending: + return "Approved" + labels = { + "manager": "Manager", + "partner": "Partner", + "review_partner": "Review Partner", + } + return "Awaiting " + " + ".join(labels[level] for level in pending) + " approval" + + +def _i1_approval_blocker( + tasks: list[ClientServiceTaskInstance], + target_task: ClientServiceTaskInstance, +) -> dict[str, Any] | None: + """Lock downstream staff work until required approval on an earlier task is complete.""" + ordered = sorted(tasks, key=lambda row: (int(getattr(row, "sequence_no", 0) or 0), int(row.id))) + for row in ordered: + if row.id == target_task.id: + break + levels = _i1_required_review_levels(row) + if not levels: + continue + pending = [level for level in levels if _i1_review_level_pending(row, level)] + if not pending: + continue + labels = { + "manager": "Manager", + "partner": "Partner", + "review_partner": "Review Partner", + } + return { + "task_id": int(row.id), + "task_name": row.task_name, + "label": _i1_review_label(row) or "Approval pending", + "levels": [labels[level] for level in pending], + } + return None + + +def _i1_prepare_task_display( + task: ClientServiceTaskInstance, + *, + tasks: list[ClientServiceTaskInstance], + task_document_counts: dict[int, int], +) -> None: + status = (getattr(task, "status", None) or "pending").strip().lower() + task.i1_status = status + task.i1_review_levels = _i1_required_review_levels(task) + task.i1_review_label = _i1_review_label(task) + task.i1_approval_blocker = _i1_approval_blocker(tasks, task) + task.i1_has_evidence = task_document_counts.get(int(task.id), 0) > 0 + task.i1_needs_response = bool( + getattr(task, "response_required", False) + or (getattr(task, "response_type", "NONE") or "NONE").strip().upper() != "NONE" + ) + task.i1_needs_evidence = bool(getattr(task, "evidence_required", False)) + task.i1_quick_complete = bool( + not task.i1_approval_blocker + and not task.i1_needs_response + and (not task.i1_needs_evidence or task.i1_has_evidence) + and not getattr(task, "is_locked", False) + and not getattr(getattr(task, "subscription", None), "is_locked", False) + ) + + 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)] @@ -3729,6 +3837,28 @@ def get_employee_engagement_work_board( manual_blocker = _workflow_manual_blocker(subscription) automatic_blocker = _workflow_automatic_blocker(subscription, tasks) + engagement_documents = list_employee_engagement_documents( + db, scope, engagement_id, financial_year=financial_year + ) + task_document_counts: dict[int, int] = {} + for document in engagement_documents: + task_instance_id = getattr(document, "task_instance_id", None) + if task_instance_id: + task_document_counts[int(task_instance_id)] = task_document_counts.get(int(task_instance_id), 0) + 1 + + for task in tasks: + _i1_prepare_task_display( + task, + tasks=tasks, + task_document_counts=task_document_counts, + ) + + scope_display = _employee_work_scope_display_map( + db, + tenant_id=scope.tenant_id, + subscriptions=[subscription] if subscription is not None else [], + ).get(int(subscription.id), {}) if subscription is not None else {} + return { "engagement_id": engagement_id, "subscription": subscription, @@ -3748,9 +3878,9 @@ def get_employee_engagement_work_board( "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 - ), + "documents": engagement_documents, + "scope_display": scope_display, + "i1_enabled": True, "today": today, } @@ -3786,9 +3916,26 @@ def save_employee_workflow_task( raise ValueError("Locked task cannot be changed.") normalised_status = (status or "pending").strip().lower() - if normalised_status not in {"pending", "in_progress", "blocked", "completed"}: + if normalised_status not in {"pending", "in_progress", "blocked", "completed", "not_applicable"}: raise ValueError("Invalid task status.") + engagement_tasks_for_gate = 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() + approval_blocker = _i1_approval_blocker(engagement_tasks_for_gate, task) + if approval_blocker and normalised_status in {"in_progress", "completed", "not_applicable"}: + raise ValueError(approval_blocker["label"]) + + clean_remarks = (remarks or "").strip() + if normalised_status == "blocked" and not clean_remarks: + raise ValueError("Select or enter a blocker reason.") + if normalised_status == "not_applicable" and not clean_remarks: + raise ValueError("A short reason is required for Not Applicable.") + apply_task_checklist_response( db, task, @@ -3801,16 +3948,27 @@ def save_employee_workflow_task( ) task.status = normalised_status - if normalised_status == "completed": + if normalised_status in CLOSED_TASK_STATUSES: 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() + if clean_remarks: + task.remarks = clean_remarks task.updated_by_user_id = actor_user_id recalculate_task_aqmm_status(db, task) + + # I1 approval gate: completing a task configured for Manager/Partner review + # automatically sends the existing task to the existing review workflow. + if normalised_status == "completed" and _i1_required_review_levels(task): + submit_task_for_review( + db, + task=task, + note=clean_remarks or "Completed by staff and submitted for approval.", + user_id=actor_user_id, + ) + db.add(task) db.commit() db.refresh(task) @@ -3826,6 +3984,64 @@ def save_employee_workflow_task( +def quick_update_employee_workflow_task( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + action: str, + reason: str, + actor_user_id: int, + financial_year: str | None = None, +) -> tuple[ClientServiceTaskInstance, ClientServiceTaskInstance | None]: + """I1 one-click task action using the existing task/checklist validation path.""" + action_code = (action or "").strip().lower() + status_map = { + "done": "completed", + "blocked": "blocked", + "na": "not_applicable", + } + if action_code not in status_map: + raise ValueError("Invalid checklist action.") + + 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") + + reason_text = (reason or "").strip() + if action_code == "blocked" and not reason_text: + raise ValueError("A blocker reason is required.") + if action_code == "na" and not reason_text: + raise ValueError("A short reason is required for Not Applicable.") + + return save_employee_workflow_task( + db, + scope, + task_id, + status=status_map[action_code], + remarks=reason_text or (getattr(task, "remarks", None) or ""), + checklist_response=getattr(task, "checklist_response", None) or "", + checklist_text_response=getattr(task, "checklist_text_response", None) or "", + checklist_number_response=( + str(getattr(task, "checklist_number_response", "")) + if getattr(task, "checklist_number_response", None) is not None + else "" + ), + checklist_date_response=( + str(getattr(task, "checklist_date_response", "")) + if getattr(task, "checklist_date_response", None) is not None + else "" + ), + checklist_remarks=getattr(task, "checklist_remarks", None) or "", + actor_user_id=actor_user_id, + financial_year=financial_year, + ) + + + 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.""" stmt = ( diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index 81dcaa0..ac789ea 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -6,10 +6,12 @@

{{ board.label }}

-

- {{ board.client.client_name if board.client else 'Unlinked Client' }} - {% if board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %} + {% set scope_display = board.scope_display or {} %} +

+ {{ scope_display.primary or (board.client.trade_name if board.client and board.client.trade_name else (board.client.client_name if board.client else 'Unlinked Client')) }} + {% if scope_display.secondary %} · {{ scope_display.secondary }}{% elif board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %}

+ {% if scope_display.context %}

{{ scope_display.context }}

{% endif %}
{% if board.manual_blocker %} @@ -20,232 +22,268 @@ {% elif not (board.subscription and board.subscription.is_locked) %} {% endif %} - Close Workspace Engagement Documents + Close
+ {% if request.query_params.get('quick_saved') %} +
Checklist updated.
+ {% endif %} + {% if request.query_params.get('quick_error') %} +
{{ request.query_params.get('quick_error')|replace('+', ' ') }}
+ {% endif %} + {% if request.query_params.get('workflow_error') %} +
The task could not be saved. Complete any required response/evidence or resolve the approval gate.
+ {% endif %} - {% if request.query_params.get('paused') %}
Engagement workflow paused and the assigned Manager was notified.
{% endif %} - {% if request.query_params.get('resumed') %}
Engagement workflow resumed.
{% endif %} - {% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}
The workflow status could not be changed. Confirm the reason and that the engagement is not locked.
{% endif %} - -
-
Weighted Progress
{{ board.progress_percent }}%
{{ board.weighted_progress.completed_weight }} of {{ board.weighted_progress.total_weight }} weighted points
-
SLA & Ageing
{{ board.sla.label }}
Age {{ board.sla.age_days }} day(s){% if board.sla.due_date %} · Due {{ board.sla.due_date }}{% endif %}
-
Engagement Team
{% for member in board.engagement_team %}{{ member.role }}: {{ member.name }}{% else %}No engagement team assigned.{% endfor %}
-
- - {% if request.query_params.get('escalated') %}
Escalation alert sent successfully.
{% endif %} - {% if request.query_params.get('escalation_error') %}
Escalation could not be sent. Confirm the selected reviewer is assigned and enter details.
{% endif %} - - {% if board.workflow_blocker %} -
-
{{ board.workflow_blocker.label }}
- {% if board.workflow_blocker.notes %}
{{ board.workflow_blocker.notes }}
{% endif %} - {% if board.workflow_blocker.follow_up_date %}
Follow-up date: {{ board.workflow_blocker.follow_up_date }}
{% endif %} - {% if board.workflow_blocker.type == 'automatic' %}
This blocker is derived automatically from the existing AQMM/review workflow.
{% endif %} + {% if board.manual_blocker %} +
+
Engagement on hold — {{ board.manual_blocker.label }}
+ {% if board.manual_blocker.notes %}
{{ board.manual_blocker.notes }}
{% endif %} + {% if board.manual_blocker.follow_up_date %}
Follow-up: {{ board.manual_blocker.follow_up_date }}
{% endif %}
{% endif %} - +
+ {% endfor %} + {% endfor %} + + + +
+ Escalate dependency or review delay +
+ + + + +
+
+ +
+
+
+

Engagement Summary

+

Existing engagement, team and document controls remain unchanged.

+
+ Open Documents +
+
+
Engagement Status
{{ board.subscription.status if board.subscription else '-' }}
+
Financial Year
{{ board.subscription.financial_year if board.subscription else '-' }}
+
Due Date
{{ board.subscription.current_due_date if board.subscription else '-' }}
+
Review Partner
{{ board.subscription.review_partner.full_name if board.subscription and board.subscription.review_partner else '-' }}
+
+
{% endblock %} diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index e6f33a7..27a7404 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -49,6 +49,7 @@ from app.modules.employees.service import ( get_employee_engagement_work_board, start_employee_engagement_workflow, save_employee_workflow_task, + quick_update_employee_workflow_task, pause_employee_engagement_workflow, resume_employee_engagement_workflow, escalate_employee_engagement_workflow, @@ -325,28 +326,12 @@ def _form_payload(form, *, include_context: bool = False): def _form_options(db, current_user, scope, *, include_user_id: int | None = None): - users = list_linkable_users(db, scope, include_user_id=include_user_id) - partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin) - - # A Partner may onboard or link only Staff and Branch Manager users. The - # service layer repeats this rule so a forged POST cannot bypass the form. - if partner_staff_mode: - allowed_roles = {"Staff", "Branch Manager"} - prohibited_roles = {"System Admin", "Firm Admin", "Partner"} - users = [ - user - for user in users - if set(get_user_roles(db, user.id)).intersection(allowed_roles) - and not set(get_user_roles(db, user.id)).intersection(prohibited_roles) - ] - return { "tenants": visible_tenants(db, current_user), "branches": visible_branches(db, current_user, scope.tenant_id), - "users": users, + "users": list_linkable_users(db, scope, include_user_id=include_user_id), "managers": list_reporting_managers(db, scope), "scope": scope, - "partner_staff_mode": partner_staff_mode, } @@ -579,7 +564,7 @@ def employee_new(request: Request): "modules/employees/templates/employees/form.html", db, current_user, - title="Add Staff" if scope.is_partner else "Add Employee", + title="Add Employee", employee=None, errors=[], mode="create", @@ -607,11 +592,6 @@ async def employee_create_submit(request: Request): return _redirect_denied() scope = build_employee_scope(db, current_user, tenant_id=form.get("tenant_id"), branch_id=form.get("branch_id")) payload = _form_payload(form, include_context=True) - if scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin: - requested_role = (payload.get("employee_role") or "Staff").strip() - payload["employee_role"] = requested_role if requested_role in {"Staff", "Branch Manager"} else "Staff" - payload["tenant_id"] = scope.tenant_id - payload["branch_id"] = scope.branch_id or current_user.branch_id try: emp = create_employee(db, current_user, scope, payload) return RedirectResponse(url=f"/employees/{emp.id}", status_code=303) @@ -621,7 +601,7 @@ async def employee_create_submit(request: Request): "modules/employees/templates/employees/form.html", db, current_user, - title="Add Staff" if scope.is_partner else "Add Employee", + title="Add Employee", employee=payload, errors=[getattr(exc, "detail", str(exc))], mode="create", @@ -2222,6 +2202,60 @@ def employee_workflow_escalate( return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?escalated=1", status_code=303) +@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/quick-action") +def employee_workflow_task_quick_action( + request: Request, + engagement_id: int, + task_id: int, + action: str = Form(...), + reason: str = Form(""), + 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 = quick_update_employee_workflow_task( + db, + scope, + task_id, + action=action, + reason=reason, + actor_user_id=current_user.id, + financial_year=_active_financial_year(request), + ) + except ValueError as exc: + db.rollback() + message = str(exc).replace(" ", "+")[:240] + return RedirectResponse( + url=f"/employee/work/engagements/{engagement_id}?task_id={task_id}&quick_error={message}", + status_code=303, + ) + + target_task = next_task or saved_task + return RedirectResponse( + url=f"/employee/work/engagements/{engagement_id}?task_id={target_task.id}&quick_saved=1", + status_code=303, + ) + finally: + db.close() + + @portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save") def employee_workflow_task_save( request: Request,