diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index ac3b9b3..c0a77ad 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -2703,113 +2703,278 @@ def list_employee_work_kanban( status: str = "open", financial_year: str | None = None, ) -> dict[str, Any]: - """Return staff self-work as engagement cards in kanban columns. + """Return exactly one employee-board card per assigned engagement. - Phase 7I does not introduce a new task table. It reuses existing - client_service_task_instances and groups assigned tasks by engagement/service - subscription so staff can open one engagement board and work through tasks. + The task engine remains unchanged. The board derives an engagement state from + the staff member's assigned tasks using this precedence: + + completed -> all assigned tasks are closed + blocked -> at least one assigned task is blocked + in_progress -> at least one assigned task is started/completed + pending -> no assigned task has started """ today = date.today() status_filter = (status or "open").strip().lower() - stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year) - - if status_filter == "open": - stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))) - elif status_filter == "closed": - stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES))) - elif status_filter in {code for code, _ in TASK_STATUSES}: - stmt = stmt.where(ClientServiceTaskInstance.status == status_filter) + valid_filters = {"open", "closed", "pending", "in_progress", "blocked", "completed"} + if status_filter not in valid_filters: + status_filter = "open" + stmt = _employee_work_task_query( + db, + scope, + assigned_only=True, + financial_year=financial_year, + ) if q.strip(): like = f"%{q.strip()}%" stmt = stmt.where( or_( ClientServiceTaskInstance.task_name.ilike(like), ClientServiceTaskInstance.description.ilike(like), - ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))), - ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))), + ClientServiceTaskInstance.client.has( + or_(Client.client_name.ilike(like), Client.client_code.ilike(like)) + ), + ClientServiceTaskInstance.catalogue.has( + or_( + ServiceCatalogue.service_name.ilike(like), + ServiceCatalogue.service_code.ilike(like), + ) + ), ) ) tasks = db.execute( stmt.order_by( + ClientServiceTaskInstance.subscription_id.asc(), + ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.internal_target_date.is_(None), ClientServiceTaskInstance.internal_target_date.asc(), - ClientServiceTaskInstance.priority.desc(), - ClientServiceTaskInstance.sequence_no.asc(), - ClientServiceTaskInstance.id.desc(), + ClientServiceTaskInstance.id.asc(), ) ).scalars().all() - 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", "cards": []}, {"code": "in_progress", "label": "In Progress", "cards": []}, {"code": "blocked", "label": "Blocked", "cards": []}, {"code": "completed", "label": "Completed", "cards": []}, ] - column_lookup = {c["code"]: c for c in columns} - card_lookup: dict[tuple[str, int], dict[str, Any]] = {} + column_lookup = {column["code"]: column for column in columns} + engagement_lookup: dict[int, dict[str, Any]] = {} for task in tasks: _phase7i_task_card_enrich(task, today=today) - status_code = (task.status or "pending").strip().lower() - is_closed = status_code in CLOSED_TASK_STATUSES - summary["completed" if is_closed else "open"] += 1 - if status_code in summary: - summary[status_code] += 1 - if task.is_overdue: - 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" subscription = getattr(task, "subscription", None) - engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0 - card_key = (column_code, engagement_id) - if card_key not in card_lookup: + engagement_id = ( + getattr(subscription, "id", None) + or getattr(task, "subscription_id", 0) + or 0 + ) + if engagement_id not in engagement_lookup: client = getattr(task, "client", None) - card = { + engagement_lookup[engagement_id] = { "engagement_id": engagement_id, "subscription": subscription, "label": _subscription_label(subscription, task), "client_name": getattr(client, "client_name", None) or "Unlinked Client", "client_code": getattr(client, "client_code", None) or "", - "service_name": getattr(getattr(subscription, "catalogue", None), "service_name", None) or getattr(getattr(task, "catalogue", None), "service_name", None) or "Service Engagement", - "financial_year": getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "-", - "due_date": getattr(subscription, "current_due_date", None) if subscription else getattr(task, "internal_target_date", None), - "status": getattr(subscription, "status", None) or "active", + "service_name": ( + getattr(getattr(subscription, "catalogue", None), "service_name", None) + or getattr(getattr(task, "catalogue", None), "service_name", None) + or "Service Engagement" + ), + "financial_year": ( + getattr(subscription, "financial_year", None) + or getattr(task, "financial_year", None) + or "-" + ), + "due_date": ( + getattr(subscription, "current_due_date", None) + if subscription + else getattr(task, "internal_target_date", None) + ), "task_count": 0, - "open_count": 0, - "completed_count": 0, + "pending_count": 0, + "in_progress_count": 0, "blocked_count": 0, + "completed_count": 0, "overdue_count": 0, "due_today_count": 0, "latest_comment": None, + "blocked_reason": None, + "blocked_task_name": None, + "next_task_name": None, "tasks": [], } - card_lookup[card_key] = card - column_lookup[column_code]["cards"].append(card) - card = card_lookup[card_key] + + card = engagement_lookup[engagement_id] + task_status = (task.status or "pending").strip().lower() + is_closed = task_status in CLOSED_TASK_STATUSES card["task_count"] += 1 card["tasks"].append(task) if is_closed: card["completed_count"] += 1 - else: - card["open_count"] += 1 - if status_code == "blocked": + elif task_status == "blocked": card["blocked_count"] += 1 + if not card["blocked_reason"]: + card["blocked_reason"] = ( + (getattr(task, "remarks", None) or "").strip() + or ( + getattr(task.latest_comment, "message", None) + if task.latest_comment + else None + ) + or "Work is awaiting a dependency or clarification." + ) + card["blocked_task_name"] = task.task_name + elif task_status == "in_progress": + card["in_progress_count"] += 1 + else: + card["pending_count"] += 1 + if task.is_overdue: card["overdue_count"] += 1 if task.is_due_today: card["due_today_count"] += 1 - if task.latest_comment and not card.get("latest_comment"): + if task.latest_comment and not card["latest_comment"]: card["latest_comment"] = task.latest_comment + if not is_closed and not card["next_task_name"]: + card["next_task_name"] = task.task_name - return {"summary": summary, "columns": columns, "q": q, "status": status_filter, "today": today} + summary = { + "total": 0, + "open": 0, + "pending": 0, + "in_progress": 0, + "blocked": 0, + "completed": 0, + "overdue": 0, + "due_today": 0, + } + 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 + card["open_count"] = max(total - completed, 0) + + if total and completed == total: + card_status = "completed" + card["action_label"] = "View" + elif card["blocked_count"]: + card_status = "blocked" + card["action_label"] = "Open / Follow Up" + elif card["in_progress_count"] or completed: + card_status = "in_progress" + card["action_label"] = "Continue" + else: + card_status = "pending" + card["action_label"] = "Start" + + card["workflow_status"] = card_status + card["is_overdue"] = bool(card["overdue_count"]) + card["is_due_today"] = bool(card["due_today_count"]) + + include = ( + status_filter == card_status + or (status_filter == "open" and card_status != "completed") + or (status_filter == "closed" and card_status == "completed") + ) + if not include: + continue + + column_lookup[card_status]["cards"].append(card) + summary["total"] += 1 + summary[card_status] += 1 + summary["completed" if card_status == "completed" else "open"] += 1 + if card["is_overdue"]: + summary["overdue"] += 1 + if card["is_due_today"]: + summary["due_today"] += 1 + + for column in columns: + column["cards"].sort( + key=lambda card: ( + card["due_date"] is None, + card["due_date"] or date.max, + card["client_name"].lower(), + card["service_name"].lower(), + ) + ) + + return { + "summary": summary, + "columns": columns, + "q": q, + "status": status_filter, + "today": today, + } + + +def start_employee_engagement_workflow( + db: Session, + scope: EmployeeScope, + engagement_id: int, + *, + actor_user_id: int, + financial_year: str | None = None, +) -> ClientServiceTaskInstance: + """Start the first pending task assigned to the employee in an engagement. + + Existing task status, lock, tenant, branch and financial-year controls are + reused. No engagement or task records are duplicated. + """ + 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(), + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.id.asc(), + ) + ).scalars().all() + if not tasks: + raise HTTPException( + status_code=404, + detail="Engagement work not found or not assigned to you", + ) + + already_started = next( + ( + task + for task in tasks + if (task.status or "").strip().lower() == "in_progress" + ), + None, + ) + if already_started: + return already_started + + first_pending = next( + ( + task + for task in tasks + if (task.status or "pending").strip().lower() == "pending" + ), + None, + ) + if not first_pending: + return tasks[0] + + return update_own_service_task_status( + db, + scope, + first_pending.id, + status="in_progress", + remarks=None, + actor_user_id=actor_user_id, + financial_year=financial_year, + ) def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> list[EngagementDocument]: stmt = ( diff --git a/app/modules/employees/templates/employees/self_work.html b/app/modules/employees/templates/employees/self_work.html index 54c505a..a58dc30 100644 --- a/app/modules/employees/templates/employees/self_work.html +++ b/app/modules/employees/templates/employees/self_work.html @@ -2,13 +2,11 @@ {% block content %}
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %} +

My Work

-

Board view of your assigned engagements. Open a card to work on tasks and refer to engagement documents.

-
-
- +

One card per assigned engagement. Start, continue, follow up or view the full workflow from here.

@@ -24,7 +22,7 @@ {% endif %}
-
Total
{{ work_payload.summary.total }}
+
Engagements
{{ work_payload.summary.total }}
Open
{{ work_payload.summary.open }}
In Progress
{{ work_payload.summary.in_progress }}
Blocked
{{ work_payload.summary.blocked }}
@@ -36,48 +34,85 @@
-
+
{% for column in work_payload.columns %} -
-
-

{{ column.label }}

- {{ column.cards|length }} +
+
+
+

{{ column.label }}

+ {% if column.code == 'pending' %}

Assigned engagements where work has not started.

{% endif %} + {% if column.code == 'in_progress' %}

Started engagements with remaining assigned work.

{% endif %} + {% if column.code == 'blocked' %}

Engagements waiting for documents, clarification, review or another dependency.

{% endif %} + {% if column.code == 'completed' %}

All tasks assigned to you in these engagements are complete.

{% endif %} +
+ {{ column.cards|length }}
-
+ +
{% for card in column.cards %} -
+
{{ card.client_code or 'Client' }}
-

{{ card.client_name }}

+

{{ card.client_name }}

- {% if card.overdue_count %}Overdue {{ card.overdue_count }}{% elif card.due_today_count %}Due today{% endif %} + {% if card.is_overdue %}Overdue{% elif card.is_due_today %}Due today{% endif %}
+
{{ card.service_name }}
FY {{ card.financial_year }} ยท Due {{ card.due_date or '-' }}
-
-
{{ card.open_count }}
Open
-
{{ card.blocked_count }}
Blocked
-
{{ card.completed_count }}
Done
+ +
+
+ My progress + {{ card.progress_percent }}% +
+
+
+
+
{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed
+ + {% if column.code == 'blocked' %} +
+
Pending reason
+
{{ card.blocked_reason }}
+ {% if card.blocked_task_name %}
Task: {{ card.blocked_task_name }}
{% endif %} +
+ {% elif card.next_task_name and column.code != 'completed' %} +
Next: {{ card.next_task_name }}
+ {% endif %} + {% if card.latest_comment %}

Latest: {{ card.latest_comment.message }}

{% endif %} - Open Work Details + + {% if column.code == 'pending' %} +
+ + +
+ {% elif column.code == 'in_progress' %} + Continue + {% elif column.code == 'blocked' %} + Open / Follow Up + {% else %} + View + {% endif %}
{% else %} -
No cards in this column.
+
No engagement cards in this section.
{% endfor %}
diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index 80cc5a9..e7b67c1 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -11,7 +11,7 @@

diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index a00ce70..f00e4a7 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -47,6 +47,7 @@ from app.modules.employees.service import ( list_employee_work_dashboard, list_employee_work_kanban, get_employee_engagement_work_board, + start_employee_engagement_workflow, list_employee_work_assignable_users, list_visible_work_assignment_dashboard, list_engagement_progress_dashboard, @@ -2047,6 +2048,48 @@ def employee_my_work(request: Request, q: str = "", status: str = "open"): db.close() +@portal_router.post("/work/engagements/{engagement_id}/start") +def employee_my_work_engagement_start( + request: Request, + engagement_id: int, + 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, + ) + start_employee_engagement_workflow( + db, + scope, + engagement_id, + actor_user_id=current_user.id, + financial_year=_active_financial_year(request), + ) + return RedirectResponse( + url=f"/employee/work/engagements/{engagement_id}", + status_code=303, + ) + finally: + db.close() + + @portal_router.get("/work/engagements/{engagement_id}") def employee_my_work_engagement_board(request: Request, engagement_id: int): db = CommonSessionLocal()