Add engagement-level employee workflow board phase 1

This commit is contained in:
A R R R Associates
2026-07-19 23:36:38 +05:30
parent df52845194
commit f25f67389e
4 changed files with 317 additions and 74 deletions
+216 -51
View File
@@ -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 = (
@@ -2,13 +2,11 @@
{% block content %}
<div class="space-y-6">
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">My Work</h2>
<p class="mt-1 text-sm text-slate-500">Board view of your assigned engagements. Open a card to work on tasks and refer to engagement documents.</p>
</div>
<div class="flex flex-wrap gap-2">
<p class="mt-1 text-sm text-slate-500">One card per assigned engagement. Start, continue, follow up or view the full workflow from here.</p>
</div>
</div>
@@ -24,7 +22,7 @@
{% endif %}
<div class="grid gap-4 md:grid-cols-6">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Total</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.total }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Engagements</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.total }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Open</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.open }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">In Progress</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.in_progress }}</div></div>
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-rose-600">Blocked</div><div class="mt-1 text-2xl font-semibold text-rose-700">{{ work_payload.summary.blocked }}</div></div>
@@ -36,48 +34,85 @@
<div class="grid gap-3 md:grid-cols-[1fr_220px_auto]">
<input type="search" name="q" value="{{ q or '' }}" placeholder="Search engagement, task, client or service" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
<option value="open" {% if status == 'open' %}selected{% endif %}>Open work</option>
<option value="open" {% if status == 'open' %}selected{% endif %}>Open engagements</option>
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="blocked" {% if status == 'blocked' %}selected{% endif %}>Blocked</option>
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Completed</option>
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed work</option>
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed engagements</option>
</select>
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply</button>
</div>
</form>
<div class="grid gap-4 xl:grid-cols-4">
<div class="space-y-5">
{% for column in work_payload.columns %}
<section class="min-h-[420px] rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-soft">
<div class="mb-3 flex items-center justify-between px-1">
<h3 class="text-sm font-semibold text-slate-900">{{ column.label }}</h3>
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ column.cards|length }}</span>
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-4 shadow-soft">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-slate-900">{{ column.label }}</h3>
{% if column.code == 'pending' %}<p class="mt-1 text-xs text-slate-500">Assigned engagements where work has not started.</p>{% endif %}
{% if column.code == 'in_progress' %}<p class="mt-1 text-xs text-slate-500">Started engagements with remaining assigned work.</p>{% endif %}
{% if column.code == 'blocked' %}<p class="mt-1 text-xs text-slate-500">Engagements waiting for documents, clarification, review or another dependency.</p>{% endif %}
{% if column.code == 'completed' %}<p class="mt-1 text-xs text-slate-500">All tasks assigned to you in these engagements are complete.</p>{% endif %}
</div>
<div class="space-y-3">
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600">{{ column.cards|length }}</span>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{% for card in column.cards %}
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow-soft">
<article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm hover:border-brand-200 hover:shadow-soft">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ card.client_code or 'Client' }}</div>
<h4 class="mt-1 text-sm font-semibold text-slate-900">{{ card.client_name }}</h4>
<h4 class="mt-1 text-base font-semibold text-slate-900">{{ card.client_name }}</h4>
</div>
{% if card.overdue_count %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue {{ card.overdue_count }}</span>{% elif card.due_today_count %}<span class="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-700">Due today</span>{% endif %}
{% if card.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% elif card.is_due_today %}<span class="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-700">Due today</span>{% endif %}
</div>
<div class="mt-3 rounded-xl bg-slate-50 p-3">
<div class="text-sm font-semibold text-slate-900">{{ card.service_name }}</div>
<div class="mt-1 text-xs text-slate-500">FY {{ card.financial_year }} · Due {{ card.due_date or '-' }}</div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.open_count }}</div><div class="text-slate-500">Open</div></div>
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.blocked_count }}</div><div class="text-slate-500">Blocked</div></div>
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.completed_count }}</div><div class="text-slate-500">Done</div></div>
<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>
</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>
<div class="mt-2 text-xs text-slate-500">{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed</div>
</div>
{% if column.code == 'blocked' %}
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3">
<div class="text-xs font-semibold uppercase text-rose-700">Pending reason</div>
<div class="mt-1 text-sm font-medium text-rose-900">{{ card.blocked_reason }}</div>
{% if card.blocked_task_name %}<div class="mt-1 text-xs text-rose-700">Task: {{ card.blocked_task_name }}</div>{% endif %}
</div>
{% elif card.next_task_name and column.code != 'completed' %}
<div class="mt-4 text-xs text-slate-500">Next: <span class="font-medium text-slate-700">{{ card.next_task_name }}</span></div>
{% endif %}
{% if card.latest_comment %}<p class="mt-3 truncate text-xs text-slate-500">Latest: {{ card.latest_comment.message }}</p>{% endif %}
<a href="/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Work Details</a>
{% if column.code == 'pending' %}
<form method="post" action="/employee/work/engagements/{{ card.engagement_id }}/start" class="mt-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Start</button>
</form>
{% elif column.code == 'in_progress' %}
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Continue</a>
{% elif column.code == 'blocked' %}
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Open / Follow Up</a>
{% else %}
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View</a>
{% endif %}
</article>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No cards in this column.</div>
<div class="md:col-span-2 xl:col-span-3 rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No engagement cards in this section.</div>
{% endfor %}
</div>
</section>
@@ -11,7 +11,7 @@
</p>
</div>
<div class="flex flex-wrap gap-2">
<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">My Work Board</a>
<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>
<a href="/documents/engagements/{{ board.engagement_id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Full Documents</a>
</div>
</div>
+43
View File
@@ -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()