Add grouped employee engagement workflow workspace phase 2

This commit is contained in:
A R R R Associates
2026-07-19 23:49:31 +05:30
parent f25f67389e
commit dfdc74b534
3 changed files with 417 additions and 100 deletions
+187 -23
View File
@@ -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 &amp; 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 %}
+72 -2
View File
@@ -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,