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()