Add grouped employee engagement workflow workspace phase 2
This commit is contained in:
@@ -22,7 +22,13 @@ from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeR
|
|||||||
from app.modules.clients.models import Client
|
from app.modules.clients.models import Client
|
||||||
from app.modules.documents.models import EngagementDocument
|
from app.modules.documents.models import EngagementDocument
|
||||||
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment
|
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"]
|
EMPLOYEE_STATUS = ["active", "inactive", "relieved"]
|
||||||
EMPLOYMENT_TYPES = ["full_time", "part_time", "article_assistant", "intern", "consultant", "contract"]
|
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()
|
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()
|
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(
|
tasks = db.execute(
|
||||||
stmt.order_by(
|
stmt.order_by(
|
||||||
ClientServiceTaskInstance.sequence_no.asc(),
|
ClientServiceTaskInstance.sequence_no.asc(),
|
||||||
@@ -3006,18 +3040,23 @@ def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engage
|
|||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
if not tasks:
|
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)
|
summary = {
|
||||||
client = getattr(tasks[0], "client", None)
|
"total": len(tasks),
|
||||||
summary = {"total": len(tasks), "open": 0, "pending": 0, "in_progress": 0, "blocked": 0, "completed": 0, "overdue": 0, "due_today": 0}
|
"open": 0,
|
||||||
columns = [
|
"pending": 0,
|
||||||
{"code": "pending", "label": "Pending", "tasks": []},
|
"in_progress": 0,
|
||||||
{"code": "in_progress", "label": "In Progress", "tasks": []},
|
"blocked": 0,
|
||||||
{"code": "blocked", "label": "Blocked", "tasks": []},
|
"completed": 0,
|
||||||
{"code": "completed", "label": "Completed", "tasks": []},
|
"overdue": 0,
|
||||||
]
|
"due_today": 0,
|
||||||
column_lookup = {c["code"]: c for c in columns}
|
}
|
||||||
|
category_lookup: dict[str, dict[str, Any]] = {}
|
||||||
|
categories: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
_phase7i_task_card_enrich(task, today=today)
|
_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
|
summary["overdue"] += 1
|
||||||
if task.is_due_today:
|
if task.is_due_today:
|
||||||
summary["due_today"] += 1
|
summary["due_today"] += 1
|
||||||
column_code = "completed" if is_closed else status_code
|
|
||||||
if column_code not in column_lookup:
|
category_name = _employee_task_category(task)
|
||||||
column_code = "pending"
|
category = category_lookup.get(category_name)
|
||||||
column_lookup[column_code]["tasks"].append(task)
|
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 {
|
return {
|
||||||
"engagement_id": engagement_id,
|
"engagement_id": engagement_id,
|
||||||
"subscription": subscription,
|
"subscription": getattr(tasks[0], "subscription", None),
|
||||||
"client": client,
|
"client": getattr(tasks[0], "client", None),
|
||||||
"label": _subscription_label(subscription, tasks[0]),
|
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"columns": columns,
|
"progress_percent": overall_progress,
|
||||||
"documents": list_employee_engagement_documents(db, scope, engagement_id, financial_year=financial_year),
|
"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,
|
"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]:
|
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."""
|
"""Users that can be assigned engagement/service tasks in the active employee scope."""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">{{ board.label }}</h2>
|
<h2 class="text-xl font-semibold text-slate-900">{{ board.label }}</h2>
|
||||||
@@ -12,106 +13,188 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<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">Close Workspace</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>
|
<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">Engagement Documents</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-6">
|
{% if request.query_params.get('saved') %}
|
||||||
<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 Tasks</div><div class="mt-1 text-2xl font-semibold">{{ board.summary.total }}</div></div>
|
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Task response saved successfully.</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">{{ board.summary.open }}</div></div>
|
{% endif %}
|
||||||
<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">{{ board.summary.in_progress }}</div></div>
|
{% if request.query_params.get('workflow_error') %}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The task could not be saved. Complete the required response, remarks and evidence, and confirm that the task is not locked.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-5">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">Overall Progress</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ board.progress_percent }}%</div>
|
||||||
|
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ board.progress_percent }}%"></div></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">Total</div><div class="mt-1 text-2xl font-semibold">{{ board.summary.total }}</div></div>
|
||||||
|
<div class="rounded-2xl border border-blue-200 bg-blue-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-blue-600">In Progress</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ board.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">{{ board.summary.blocked }}</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">{{ board.summary.blocked }}</div></div>
|
||||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-red-600">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ board.summary.overdue }}</div></div>
|
|
||||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ board.summary.completed }}</div></div>
|
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ board.summary.completed }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
<div class="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
|
<aside class="space-y-4">
|
||||||
{% for column in board.columns %}
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||||
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-soft">
|
<div class="mb-4">
|
||||||
<div class="mb-3 flex items-center justify-between px-1">
|
<h3 class="font-semibold text-slate-900">Workflow Groups</h3>
|
||||||
<h3 class="text-sm font-semibold text-slate-900">{{ column.label }}</h3>
|
<p class="mt-1 text-sm text-slate-500">Tasks assigned to you, grouped by task category.</p>
|
||||||
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ column.tasks|length }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{% for task in column.tasks %}
|
{% for category in board.categories %}
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm {% if task.is_overdue %}border-red-200 bg-red-50{% elif task.is_due_today %}border-amber-200 bg-amber-50{% endif %}">
|
<div class="rounded-xl border {% if category.name == board.active_category %}border-brand-300 bg-brand-50{% else %}border-slate-200 bg-white{% endif %} p-3">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-slate-900">{{ task.task_name }}</h4>
|
<div class="text-sm font-semibold text-slate-900">{{ category.name }}</div>
|
||||||
{% if task.description %}<p class="mt-1 line-clamp-3 text-xs text-slate-500">{{ task.description }}</p>{% endif %}
|
<div class="mt-1 text-xs text-slate-500">{{ category.completed }} of {{ category.total }} completed</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="rounded-full bg-white px-2 py-1 text-[11px] font-semibold text-slate-600">#{{ task.sequence_no }}</span>
|
<span class="rounded-full px-2 py-1 text-[11px] font-semibold {% if category.status == 'completed' %}bg-emerald-100 text-emerald-700{% elif category.status == 'blocked' %}bg-rose-100 text-rose-700{% elif category.status == 'in_progress' %}bg-blue-100 text-blue-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ category.status.replace('_', ' ').title() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold">
|
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ category.progress_percent }}%"></div></div>
|
||||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.priority_label }}</span>
|
<div class="mt-3 space-y-1.5">
|
||||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.date_bucket }}</span>
|
{% for task in category.tasks %}
|
||||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Target {{ task.internal_target_date or '-' }}</span>
|
<a href="/employee/work/engagements/{{ board.engagement_id }}?task_id={{ task.id }}" class="flex items-center justify-between gap-2 rounded-lg px-2 py-2 text-xs {% if board.active_task and task.id == board.active_task.id %}bg-white font-semibold text-brand-700 shadow-sm{% else %}text-slate-600 hover:bg-slate-50{% endif %}">
|
||||||
|
<span class="min-w-0 truncate">#{{ task.sequence_no }} {{ task.task_name }}</span>
|
||||||
|
<span class="shrink-0 rounded-full px-1.5 py-0.5 {% if task.status == 'completed' %}bg-emerald-100 text-emerald-700{% elif task.status == 'blocked' %}bg-rose-100 text-rose-700{% elif task.status == 'in_progress' %}bg-blue-100 text-blue-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ (task.status or 'pending').replace('_',' ').title() }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if task.latest_comment %}<p class="mt-3 truncate text-xs text-slate-500">Latest: {{ task.latest_comment.message }}</p>{% endif %}
|
</div>
|
||||||
<form method="post" action="/employee/work/tasks/{{ task.id }}/status" class="mt-4 space-y-2">
|
{% endfor %}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
</div>
|
||||||
<input type="hidden" name="return_url" value="/employee/work/engagements/{{ board.engagement_id }}">
|
</div>
|
||||||
<select name="status" class="w-full rounded-lg border border-slate-300 px-2 py-1.5 text-xs">
|
</aside>
|
||||||
|
|
||||||
|
<main class="space-y-4">
|
||||||
|
{% set task = board.active_task %}
|
||||||
|
{% if task %}
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ board.active_category }}</div>
|
||||||
|
<h3 class="mt-1 text-lg font-semibold text-slate-900">{{ task.task_name }}</h3>
|
||||||
|
{% if task.description %}<p class="mt-2 whitespace-pre-line text-sm leading-6 text-slate-600">{{ task.description }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs font-semibold">
|
||||||
|
<span class="rounded-full bg-slate-100 px-3 py-1 text-slate-700">Task #{{ task.sequence_no }}</span>
|
||||||
|
{% if task.response_required %}<span class="rounded-full bg-indigo-100 px-3 py-1 text-indigo-700">Response Required</span>{% endif %}
|
||||||
|
{% if task.evidence_required or task.aqmm_evidence_required %}<span class="rounded-full bg-amber-100 px-3 py-1 text-amber-700">Evidence Required</span>{% endif %}
|
||||||
|
{% if task.is_locked or (task.subscription and task.subscription.is_locked) %}<span class="rounded-full bg-slate-200 px-3 py-1 text-slate-700">Locked</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/tasks/{{ task.id }}/save" class="mt-6 space-y-5">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Task Status</label>
|
||||||
|
<select name="status" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||||
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
|
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
|
||||||
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||||
<option value="blocked" {% if task.status == 'blocked' %}selected{% endif %}>Blocked</option>
|
<option value="blocked" {% if task.status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||||
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
|
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" name="remarks" value="" placeholder="Remark / reason" class="w-full rounded-lg border border-slate-300 px-2 py-1.5 text-xs">
|
</div>
|
||||||
<div class="flex gap-2">
|
<div>
|
||||||
<button type="submit" class="flex-1 rounded-lg bg-brand-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-brand-700">Save</button>
|
<label class="mb-2 block text-sm font-medium text-slate-700">Work Remark / Block Reason</label>
|
||||||
<a href="/employee/work/tasks/{{ task.id }}/communication" class="rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Timeline{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a>
|
<input type="text" name="remarks" value="{{ task.remarks or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Work performed, pending document or hold reason" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</article>
|
|
||||||
{% else %}
|
|
||||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No tasks.</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="space-y-4">
|
{% set response_type = task.response_type or 'NONE' %}
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
{% if response_type != 'NONE' or task.response_required %}
|
||||||
|
<div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="font-semibold text-indigo-950">Checklist Response</h4>
|
||||||
|
<p class="mt-1 text-xs text-indigo-700">Save a draft at any stage. Required controls are enforced when the task is marked Completed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if response_type in ['YES_NO_NA', 'YES_NO'] %}
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Response{% if task.response_required %} *{% endif %}</label>
|
||||||
|
<select name="checklist_response" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||||
|
<option value="">Select response</option>
|
||||||
|
<option value="YES" {% if task.checklist_response == 'YES' %}selected{% endif %}>Yes</option>
|
||||||
|
<option value="NO" {% if task.checklist_response == 'NO' %}selected{% endif %}>No</option>
|
||||||
|
{% if response_type == 'YES_NO_NA' %}<option value="NA" {% if task.checklist_response == 'NA' %}selected{% endif %}>Not Applicable</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% elif response_type == 'TEXT' %}
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Text Response{% if task.response_required %} *{% endif %}</label>
|
||||||
|
<textarea name="checklist_text_response" rows="4" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>{{ task.checklist_text_response or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
{% elif response_type == 'NUMBER' %}
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Number Response{% if task.response_required %} *{% endif %}</label>
|
||||||
|
<input type="number" step="any" name="checklist_number_response" value="{{ task.checklist_number_response if task.checklist_number_response is not none else '' }}" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
{% elif response_type == 'DATE' %}
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Date Response{% if task.response_required %} *{% endif %}</label>
|
||||||
|
<input type="date" name="checklist_date_response" value="{{ task.checklist_date_response or '' }}" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Checklist Remarks{% if task.remarks_required_if_no %} · mandatory for No{% endif %}</label>
|
||||||
|
<textarea name="checklist_remarks" rows="4" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" placeholder="Document conclusion, exception, NA rationale or follow-up" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>{{ task.checklist_remarks or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="checklist_response" value="">
|
||||||
|
<input type="hidden" name="checklist_text_response" value="">
|
||||||
|
<input type="hidden" name="checklist_number_response" value="">
|
||||||
|
<input type="hidden" name="checklist_date_response" value="">
|
||||||
|
<input type="hidden" name="checklist_remarks" value="">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-slate-900">Evidence and Communication</h4>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Use the existing document and timeline controls. Uploaded task documents remain linked to this task and engagement.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/documents/tasks/{{ task.id }}" class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50">Open Evidence</a>
|
||||||
|
<a href="/employee/work/tasks/{{ task.id }}/communication" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Communication Timeline{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-5">
|
||||||
|
<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>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button type="submit" name="workflow_action" value="save" class="rounded-xl border border-brand-300 bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save</button>
|
||||||
|
<button type="submit" name="workflow_action" value="save_next" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save & Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-10 text-center text-sm text-slate-500">No assigned workflow task is available.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-slate-900">Engagement Documents</h3>
|
<h3 class="font-semibold text-slate-900">Engagement Summary</h3>
|
||||||
<p class="mt-1 text-sm text-slate-500">Quick access while performing tasks.</p>
|
<p class="mt-1 text-sm text-slate-500">Existing engagement details and document controls remain unchanged.</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/documents/engagements/{{ board.engagement_id }}" class="text-sm font-semibold text-brand-700 hover:text-brand-800">Open</a>
|
<a href="/documents/engagements/{{ board.engagement_id }}" class="text-sm font-semibold text-brand-700 hover:text-brand-800">Open Documents</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 divide-y divide-slate-100">
|
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{% for doc in board.documents %}
|
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Engagement Status</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.status if board.subscription else '-' }}</dd></div>
|
||||||
<div class="py-3">
|
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Financial Year</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.financial_year if board.subscription else '-' }}</dd></div>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Due Date</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.current_due_date if board.subscription else '-' }}</dd></div>
|
||||||
<div>
|
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Review Partner</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.review_partner.full_name if board.subscription and board.subscription.review_partner else '-' }}</dd></div>
|
||||||
<div class="text-sm font-semibold text-slate-900">{{ doc.title }}</div>
|
|
||||||
<div class="mt-1 text-xs text-slate-500">{{ doc.document_type }} · v{{ doc.current_version_no }} · {{ doc.status }}</div>
|
|
||||||
</div>
|
|
||||||
{% if doc.current_version_no and doc.versions %}
|
|
||||||
<a href="/documents/{{ doc.id }}/download" class="rounded-lg border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50">Download</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if doc.description %}<p class="mt-2 text-xs text-slate-500">{{ doc.description }}</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="rounded-xl border border-dashed border-slate-300 p-5 text-center text-sm text-slate-500">No engagement document uploaded yet.</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
|
||||||
<h3 class="font-semibold text-slate-900">Engagement Summary</h3>
|
|
||||||
<dl class="mt-4 space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">Status</dt><dd class="font-medium text-slate-900">{{ board.subscription.status if board.subscription else '-' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">FY</dt><dd class="font-medium text-slate-900">{{ board.subscription.financial_year if board.subscription else '-' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">Due Date</dt><dd class="font-medium text-slate-900">{{ board.subscription.current_due_date if board.subscription else '-' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">Review Partner</dt><dd class="font-medium text-slate-900">{{ board.subscription.review_partner.full_name if board.subscription and board.subscription.review_partner else '-' }}</dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</section>
|
||||||
</aside>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from app.modules.employees.service import (
|
|||||||
list_employee_work_kanban,
|
list_employee_work_kanban,
|
||||||
get_employee_engagement_work_board,
|
get_employee_engagement_work_board,
|
||||||
start_employee_engagement_workflow,
|
start_employee_engagement_workflow,
|
||||||
|
save_employee_workflow_task,
|
||||||
list_employee_work_assignable_users,
|
list_employee_work_assignable_users,
|
||||||
list_visible_work_assignment_dashboard,
|
list_visible_work_assignment_dashboard,
|
||||||
list_engagement_progress_dashboard,
|
list_engagement_progress_dashboard,
|
||||||
@@ -2091,7 +2092,7 @@ def employee_my_work_engagement_start(
|
|||||||
|
|
||||||
|
|
||||||
@portal_router.get("/work/engagements/{engagement_id}")
|
@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()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user(request, db=db)
|
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)
|
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
|
||||||
employee = get_employee_for_user(db, current_user)
|
employee = get_employee_for_user(db, current_user)
|
||||||
financial_year = _active_financial_year(request)
|
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(
|
return _render(
|
||||||
request,
|
request,
|
||||||
"modules/employees/templates/employees/work_engagement_board.html",
|
"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()
|
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")
|
@router.get("/work/tasks/{task_id}/communication")
|
||||||
def employee_work_task_communication(
|
def employee_work_task_communication(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
Reference in New Issue
Block a user