Show engagement progress instead of internal task status to consultants

This commit is contained in:
A R R R Associates
2026-07-23 12:59:47 +05:30
parent 4026279ba8
commit d113db0d20
5 changed files with 449 additions and 107 deletions
+337 -20
View File
@@ -15,6 +15,7 @@ from app.modules.services.models import (
ClientServiceTaskInstance, ClientServiceTaskInstance,
ServiceCatalogue, ServiceCatalogue,
ServiceTaskComment, ServiceTaskComment,
ServiceTaskDocumentRequest,
) )
CONSULTANT_BOARD_COLUMNS = OrderedDict( CONSULTANT_BOARD_COLUMNS = OrderedDict(
@@ -62,31 +63,337 @@ def _matches_search(*values: Any, q: str = "") -> bool:
return any(term in str(v or "").lower() for v in values) return any(term in str(v or "").lower() for v in values)
TASK_PROGRESS_WEIGHTS = {
"pending": 0,
"assigned": 10,
"accepted": 10,
"blocked": 20,
"awaiting_documents": 20,
"document_pending": 20,
"clarification_required": 25,
"in_progress": 50,
"under_process": 50,
"processing": 50,
"started": 50,
"rework_required": 60,
"pending_review": 80,
"ready_for_review": 80,
"submitted": 80,
"completed": 100,
"approved": 100,
"accepted_final": 100,
"closed": 100,
"locked": 100,
}
def _task_progress(task: ClientServiceTaskInstance) -> int:
status = (task.status or "pending").strip().lower()
if status == "completed" and (task.manager_review_status or "").lower() in {"pending", "submitted"}:
return 80
if status == "completed" and (task.partner_review_status or "").lower() in {"pending", "submitted"}:
return 90
if (task.consultant_assignment_status or "").lower() == "rework_required":
return 60
return TASK_PROGRESS_WEIGHTS.get(status, 0)
def _friendly_pause_reason(code: str | None, notes: str | None) -> tuple[str, str]:
value = (code or "").strip().lower()
mappings = {
"documents_required": ("Documents pending from client", "Client"),
"clarification_needed": ("Clarification pending from client", "Client"),
"client_asked_to_hold": ("Engagement on hold at client request", "Client"),
"payment_pending": ("Payment or commercial confirmation pending", "Client"),
"government_portal_issue": ("Government portal issue", "Government department"),
"third_party_information": ("Third-party information awaited", "Third party"),
"consultant_response_pending": ("Clarification pending from consultant", "Consultant"),
"internal_review": ("Internal review in progress", "Firm"),
"partner_review": ("Final review in progress", "Firm"),
}
text, pending_from = mappings.get(value, ("Engagement temporarily on hold", "Firm"))
if notes and pending_from != "Firm":
text = notes.strip()
return text, pending_from
def _engagement_progress_payload(
db: Session,
*,
subscription: ClientServiceSubscription,
client: Client,
catalogue: ServiceCatalogue,
link: ClientConsultantLink,
tasks: list[ClientServiceTaskInstance],
) -> dict:
applicable = [task for task in tasks if task.is_active]
percentage = round(sum(_task_progress(task) for task in applicable) / len(applicable)) if applicable else 0
completed_count = sum(1 for task in applicable if _task_progress(task) >= 100)
under_review = any(_task_progress(task) in {80, 90} for task in applicable)
in_progress = any(0 < _task_progress(task) < 80 for task in applicable)
blocker_text = "No blocker"
pending_from = "No pending action"
blocker_code = "none"
if subscription.workflow_paused_at_utc and not subscription.workflow_resumed_at_utc:
blocker_text, pending_from = _friendly_pause_reason(subscription.workflow_pause_reason, subscription.workflow_pause_notes)
blocker_code = (subscription.workflow_pause_reason or "on_hold").lower()
else:
open_requests = db.execute(
select(ServiceTaskDocumentRequest)
.where(
ServiceTaskDocumentRequest.tenant_id == subscription.tenant_id,
ServiceTaskDocumentRequest.subscription_id == subscription.id,
ServiceTaskDocumentRequest.is_active.is_(True),
ServiceTaskDocumentRequest.status.in_(["pending", "clarification_required", "rejected"]),
)
.order_by(ServiceTaskDocumentRequest.due_date.asc(), ServiceTaskDocumentRequest.created_at_utc.asc())
).scalars().all()
if open_requests:
request = open_requests[0]
recipient = (request.requested_from or "client_and_consultant").lower()
pending_from = "Consultant" if recipient == "consultant" else "Client"
if recipient == "client_and_consultant":
pending_from = "Client / Consultant"
blocker_code = "document_or_clarification_pending"
blocker_text = request.title or "Documents or clarification awaited"
elif subscription.quality_block_reason:
blocker_code = "quality_clearance_pending"
blocker_text = "Firm quality clearance in progress"
pending_from = "Firm"
elif any((task.consultant_assignment_status or "").lower() in {"offered", "rework_required"} for task in applicable):
blocker_code = "consultant_action_pending"
blocker_text = "Consultant action or rework pending"
pending_from = "Consultant"
elif any((task.rework_status or "").lower() == "requested" for task in applicable):
blocker_code = "internal_rework"
blocker_text = "Internal review and correction in progress"
pending_from = "Firm"
if percentage >= 100 or (subscription.status or "").lower() in {"completed", "closed", "locked"}:
stage = "Completed"
blocker_text = "No blocker"
pending_from = "No pending action"
blocker_code = "none"
elif subscription.workflow_paused_at_utc and not subscription.workflow_resumed_at_utc:
stage = "On Hold"
elif blocker_code == "document_or_clarification_pending":
stage = "Documents / Clarification Awaited"
elif under_review:
stage = "Under Review"
elif percentage >= 90:
stage = "Finalisation in Progress"
elif in_progress or percentage > 0:
stage = "Preparation in Progress"
else:
stage = "Not Started"
due_date = subscription.current_due_date or subscription.original_due_date or subscription.expiry_date
assigned_tasks = [
task for task in applicable
if task.execution_mode == "consultant" and task.assigned_consultant_id == link.consultant_id
]
last_update = max(
[subscription.updated_at_utc] + [task.updated_at_utc for task in applicable if task.updated_at_utc],
default=subscription.updated_at_utc,
)
return {
"subscription": subscription,
"client": client,
"catalogue": catalogue,
"link": link,
"progress_percentage": max(0, min(100, percentage)),
"external_stage": stage,
"blocker_code": blocker_code,
"blocker_text": blocker_text,
"pending_from": pending_from,
"due_date": due_date,
"is_overdue": bool(due_date and due_date < date.today() and percentage < 100),
"total_tasks": len(applicable),
"completed_tasks": completed_count,
"assigned_tasks": assigned_tasks,
"last_update_at": last_update,
}
def _effective_link(link: ClientConsultantLink, today: date) -> bool:
return not (link.effective_from and link.effective_from > today) and not (link.effective_to and link.effective_to < today)
def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict: def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict:
links = db.execute(select(ClientConsultantLink).where(ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), ClientConsultantLink.can_view_task_status.is_(True))).scalars().all() today = date.today()
links = db.execute(
select(ClientConsultantLink).where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
ClientConsultantLink.can_view_engagements.is_(True),
# Backward-compatible permission: this now controls engagement-level
# progress visibility, not exposure of every internal task status.
ClientConsultantLink.can_view_task_status.is_(True),
)
).scalars().all()
links = [link for link in links if _effective_link(link, today)]
links_by_client: dict[int, list[ClientConsultantLink]] = {} links_by_client: dict[int, list[ClientConsultantLink]] = {}
for link in links: for link in links:
links_by_client.setdefault(int(link.client_id), []).append(link) links_by_client.setdefault(int(link.client_id), []).append(link)
columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()}
items=[] engagements: list[dict] = []
assigned_tasks: list[dict] = []
if links_by_client: if links_by_client:
rows=db.execute(select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue).join(ClientServiceSubscription, ClientServiceSubscription.id==ClientServiceTaskInstance.subscription_id).join(Client, Client.id==ClientServiceTaskInstance.client_id).join(ServiceCatalogue, ServiceCatalogue.id==ClientServiceTaskInstance.service_catalogue_id).where(ClientServiceTaskInstance.tenant_id==consultant.tenant_id, ClientServiceTaskInstance.client_id.in_(list(links_by_client)), ClientServiceTaskInstance.is_active.is_(True)).order_by(ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.id.desc()).limit(500)).all() subscriptions = db.execute(
for task, subscription, client, catalogue in rows: select(ClientServiceSubscription, Client, ServiceCatalogue)
allowed=next((ln for ln in links_by_client[int(client.id)] if ln.service_catalogue_id in (None, task.service_catalogue_id)),None) .join(Client, Client.id == ClientServiceSubscription.client_id)
if not allowed or not _matches_search(client.client_name, getattr(client,"client_code",""), catalogue.service_name, task.task_name, q=q): continue .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
key=_board_key_for_status(task.status) .where(
if status and key!=status: continue ClientServiceSubscription.tenant_id == consultant.tenant_id,
latest=db.execute(select(ServiceTaskComment).where(ServiceTaskComment.task_instance_id==task.id, ServiceTaskComment.visibility=="consultant", ServiceTaskComment.is_deleted.is_(False)).order_by(ServiceTaskComment.created_at_utc.desc()).limit(1)).scalars().first() ClientServiceSubscription.client_id.in_(list(links_by_client)),
item={"comment":latest,"task":task,"subscription":subscription,"client":client,"catalogue":catalogue,"board_key":key,"link":allowed,"is_overdue":bool(task.internal_target_date and task.internal_target_date<date.today())} ClientServiceSubscription.is_active.is_(True),
columns[key]["items"].append(item); items.append(item) )
service_requests=db.execute(select(ConsultantServiceRequest).options(selectinload(ConsultantServiceRequest.managed_client),selectinload(ConsultantServiceRequest.firm_client),selectinload(ConsultantServiceRequest.service_catalogue)).where(ConsultantServiceRequest.tenant_id==consultant.tenant_id,ConsultantServiceRequest.consultant_id==consultant.id,ConsultantServiceRequest.is_active.is_(True)).order_by(ConsultantServiceRequest.created_at_utc.desc()).limit(50)).scalars().all() .order_by(ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc())
return {"columns":columns,"column_options":list(CONSULTANT_BOARD_COLUMNS.items()),"total_tasks":len(items),"service_requests":service_requests} .limit(500)
).all()
subscription_ids = [int(row[0].id) for row in subscriptions]
tasks_by_subscription: dict[int, list[ClientServiceTaskInstance]] = {}
if subscription_ids:
all_tasks = db.execute(
select(ClientServiceTaskInstance)
.where(
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.subscription_id.in_(subscription_ids),
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
).scalars().all()
for task in all_tasks:
tasks_by_subscription.setdefault(int(task.subscription_id), []).append(task)
for subscription, client, catalogue in subscriptions:
allowed = next(
(
link for link in links_by_client[int(client.id)]
if link.service_catalogue_id in (None, subscription.service_catalogue_id)
),
None,
)
if not allowed:
continue
item = _engagement_progress_payload(
db,
subscription=subscription,
client=client,
catalogue=catalogue,
link=allowed,
tasks=tasks_by_subscription.get(int(subscription.id), []),
)
if not _matches_search(
client.client_name,
getattr(client, "client_code", ""),
catalogue.service_name,
subscription.financial_year,
subscription.assessment_year,
item["external_stage"],
item["blocker_text"],
q=q,
):
continue
if status and (item["external_stage"] or "").lower().replace(" / ", "_").replace(" ", "_") != status:
continue
engagements.append(item)
for task in item["assigned_tasks"]:
assigned_tasks.append({"task": task, "client": client, "catalogue": catalogue, "subscription": subscription, "is_overdue": bool(task.consultant_due_date and task.consultant_due_date < today)})
service_requests = db.execute(
select(ConsultantServiceRequest)
.options(selectinload(ConsultantServiceRequest.managed_client), selectinload(ConsultantServiceRequest.firm_client), selectinload(ConsultantServiceRequest.service_catalogue))
.where(
ConsultantServiceRequest.tenant_id == consultant.tenant_id,
ConsultantServiceRequest.consultant_id == consultant.id,
ConsultantServiceRequest.is_active.is_(True),
)
.order_by(ConsultantServiceRequest.created_at_utc.desc())
.limit(50)
).scalars().all()
stage_options = sorted({(item["external_stage"].lower().replace(" / ", "_").replace(" ", "_"), item["external_stage"]) for item in engagements})
return {
"engagements": engagements,
"assigned_tasks": assigned_tasks,
"stage_options": stage_options,
"total_engagements": len(engagements),
"average_progress": round(sum(item["progress_percentage"] for item in engagements) / len(engagements)) if engagements else 0,
"blocked_engagements": sum(1 for item in engagements if item["blocker_code"] != "none"),
"overdue_engagements": sum(1 for item in engagements if item["is_overdue"]),
"service_requests": service_requests,
}
def get_consultant_engagement_progress(db: Session, *, consultant: ConsultantProfile, subscription_id: int) -> dict | None:
row = db.execute(
select(ClientServiceSubscription, Client, ServiceCatalogue)
.join(Client, Client.id == ClientServiceSubscription.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
.where(
ClientServiceSubscription.id == subscription_id,
ClientServiceSubscription.tenant_id == consultant.tenant_id,
ClientServiceSubscription.is_active.is_(True),
)
).first()
if not row:
return None
subscription, client, catalogue = row
link = db.execute(
select(ClientConsultantLink).where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.client_id == client.id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
ClientConsultantLink.can_view_engagements.is_(True),
ClientConsultantLink.can_view_task_status.is_(True),
or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == subscription.service_catalogue_id),
)
).scalars().first()
if not link or not _effective_link(link, date.today()):
return None
tasks = db.execute(
select(ClientServiceTaskInstance)
.where(
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.subscription_id == subscription.id,
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
).scalars().all()
item = _engagement_progress_payload(db, subscription=subscription, client=client, catalogue=catalogue, link=link, tasks=tasks)
communications = db.execute(
select(ServiceTaskComment)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.where(
ServiceTaskComment.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.subscription_id == subscription.id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
)
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
.limit(25)
).scalars().all()
pending_requirements = []
if link.can_view_document_requests:
pending_requirements = db.execute(
select(ServiceTaskDocumentRequest)
.where(
ServiceTaskDocumentRequest.tenant_id == consultant.tenant_id,
ServiceTaskDocumentRequest.subscription_id == subscription.id,
ServiceTaskDocumentRequest.is_active.is_(True),
ServiceTaskDocumentRequest.status.in_(["pending", "clarification_required", "rejected"]),
)
.order_by(ServiceTaskDocumentRequest.due_date.asc(), ServiceTaskDocumentRequest.created_at_utc.asc())
).scalars().all()
item.update({"communications": communications, "pending_requirements": pending_requirements})
return item
def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None: def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None:
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False)
if not client_ids:
return None
row = db.execute( row = db.execute(
select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
.join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id) .join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id)
@@ -95,15 +402,25 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi
.where( .where(
ClientServiceTaskInstance.id == task_id, ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.client_id.in_(client_ids),
ClientServiceTaskInstance.is_active.is_(True), ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.execution_mode == "consultant",
ClientServiceTaskInstance.assigned_consultant_id == consultant.id,
) )
).first() ).first()
if not row: if not row:
return None return None
task, subscription, client, catalogue = row task, subscription, client, catalogue = row
link = db.execute(select(ClientConsultantLink).where(ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.client_id == client.id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == task.service_catalogue_id))).scalars().first() link = db.execute(
if not link: select(ClientConsultantLink).where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.client_id == client.id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
ClientConsultantLink.can_view_engagements.is_(True),
or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == task.service_catalogue_id),
)
).scalars().first()
if not link or not _effective_link(link, date.today()):
return None return None
timeline = db.execute( timeline = db.execute(
select(ServiceTaskComment) select(ServiceTaskComment)
@@ -0,0 +1,22 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4"><div><h2 class="text-xl font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</h2><p class="text-sm text-slate-500">Engagement progress report • {{ subscription.financial_year }}{% if subscription.assessment_year %} • AY {{ subscription.assessment_year }}{% endif %}</p></div><a href="/consultant/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Engagement Report</a></div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Overall Progress</div><div class="mt-2 text-3xl font-bold text-slate-900">{{ progress_percentage }}%</div><div class="mt-3 h-3 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ progress_percentage }}%"></div></div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Current Stage</div><div class="mt-2 text-lg font-semibold text-blue-700">{{ external_stage }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Pending From</div><div class="mt-2 text-lg font-semibold text-slate-900">{{ pending_from }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Statutory / Target Due Date</div><div class="mt-2 text-lg font-semibold {% if is_overdue %}text-red-700{% else %}text-slate-900{% endif %}">{{ due_date.strftime('%d-%m-%Y') if due_date else 'Not set' }}</div>{% if is_overdue %}<div class="mt-1 text-xs font-semibold text-red-700">Overdue</div>{% endif %}</div>
</div>
<div class="rounded-2xl border {% if blocker_code != 'none' %}border-amber-200 bg-amber-50{% else %}border-emerald-200 bg-emerald-50{% endif %} p-5 shadow-soft"><div class="text-xs uppercase tracking-wide {% if blocker_code != 'none' %}text-amber-700{% else %}text-emerald-700{% endif %}">Current Blocker</div><div class="mt-2 text-lg font-semibold {% if blocker_code != 'none' %}text-amber-900{% else %}text-emerald-900{% endif %}">{{ blocker_text }}</div><p class="mt-2 text-sm text-slate-600">Internal staff assignments, task statuses and private review remarks are intentionally not displayed.</p></div>
<div class="grid gap-6 xl:grid-cols-[1fr_380px]">
<section class="rounded-2xl border border-slate-200 bg-white shadow-soft"><div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Meaningful Engagement Updates</h3><p class="text-xs text-slate-500">Only communications shared with the consultant are shown.</p></div><div class="divide-y divide-slate-100">{% for note in communications %}<div class="p-5"><div class="flex flex-wrap items-center justify-between gap-2"><div class="text-sm font-semibold text-slate-900">{{ note.comment_type.replace('_',' ').title() }}</div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}</div></div><div class="mt-2 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ note.message }}</div></div>{% else %}<div class="p-6 text-sm text-slate-500">No consultant-visible update has been shared yet.</div>{% endfor %}</div></section>
<aside class="space-y-6"><div class="rounded-2xl border border-slate-200 bg-white shadow-soft"><div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Pending Requirements</h3></div><div class="divide-y divide-slate-100">{% for request_row in pending_requirements %}<div class="p-4"><div class="font-semibold text-slate-900">{{ request_row.title }}</div><div class="mt-1 text-xs text-slate-500">{{ request_row.request_type.replace('_',' ').title() }} • Pending from {{ request_row.requested_from.replace('_',' ').title() }}{% if request_row.due_date %} • Due {{ request_row.due_date.strftime('%d-%m-%Y') }}{% endif %}</div>{% if request_row.description %}<div class="mt-2 text-sm text-slate-600">{{ request_row.description }}</div>{% endif %}</div>{% else %}<div class="p-4 text-sm text-slate-500">No pending requirement is shared with you.</div>{% endfor %}</div></div>
{% if assigned_tasks %}<div class="rounded-2xl border border-indigo-200 bg-indigo-50 shadow-soft"><div class="border-b border-indigo-200 px-4 py-3"><h3 class="font-semibold text-slate-900">My Assigned Execution Tasks</h3></div><div class="divide-y divide-indigo-100">{% for task in assigned_tasks %}<a href="/consultant/assignments/{{ task.id }}" class="block p-4 hover:bg-indigo-100"><div class="font-semibold text-indigo-900">{{ task.task_name }}</div><div class="mt-1 text-xs text-indigo-700">{{ task.consultant_assignment_status.replace('_',' ').title() }}{% if task.consultant_due_date %} • Due {{ task.consultant_due_date.strftime('%d-%m-%Y') }}{% endif %}</div></a>{% endfor %}</div></div>{% endif %}</aside>
</div>
</div>
{% endblock %}
@@ -2,42 +2,36 @@
<div class="af-card"> <div class="af-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div>
<h3 class="text-lg font-semibold text-slate-900">Assigned Work</h3> <h3 class="text-lg font-semibold text-slate-900">Engagement Progress</h3>
<p class="mt-1 text-sm text-slate-500">Consultant-visible assignments and firm task communications.</p> <p class="mt-1 text-sm text-slate-500">Overall engagement progress and external blockers. Detailed internal task status is not exposed.</p>
</div> </div>
<a href="/consultant/work" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Open Full Work Board</a> <a href="/consultant/work" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Open Full Engagement Report</a>
</div> </div>
</div> </div>
{% set work_board = payload.work_board if payload.work_board is defined else {} %} {% set work_board = payload.work_board if payload.work_board is defined else {} %}
{% if work_board.columns %} {% if work_board.engagements %}
<section class="grid gap-4 xl:grid-cols-3"> <section class="grid gap-4 xl:grid-cols-3">
{% for key, column in work_board.columns.items() %} {% for item in work_board.engagements[:9] %}
<div class="rounded-3xl border border-slate-200 bg-white p-4 shadow-soft"> <a href="/consultant/engagements/{{ item.subscription.id }}" class="rounded-3xl border border-slate-200 bg-white p-4 shadow-soft hover:border-brand-300">
<div class="flex items-center justify-between"> <div class="flex items-start justify-between gap-3">
<h4 class="font-semibold text-slate-900">{{ column.label }}</h4> <div><div class="font-semibold text-slate-900">{{ item.client.client_name }}</div><div class="mt-1 text-xs text-slate-500">{{ item.catalogue.service_name }} • {{ item.subscription.financial_year }}</div></div>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">{{ column.items|length }}</span> {% if item.is_overdue %}<span class="rounded-full bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% endif %}
</div> </div>
<div class="mt-4 space-y-3"> <div class="mt-4 flex items-center justify-between text-xs font-semibold text-slate-700"><span>{{ item.external_stage }}</span><span>{{ item.progress_percentage }}%</span></div>
{% for item in column.items[:5] %} <div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ item.progress_percentage }}%"></div></div>
{% set task = item.task %}{% set client = item.client %}{% set catalogue = item.catalogue %}{% set comment = item.comment %} <div class="mt-3 rounded-xl {% if item.blocker_code != 'none' %}bg-amber-50 text-amber-800{% else %}bg-emerald-50 text-emerald-700{% endif %} p-2 text-xs"><strong>Blocker:</strong> {{ item.blocker_text }}<br><strong>Pending from:</strong> {{ item.pending_from }}</div>
<a href="/consultant/assignments/{{ task.id }}" class="block rounded-2xl border {% if item.is_overdue %}border-red-200 bg-red-50{% else %}border-slate-200 hover:bg-slate-50{% endif %} p-3">
<div class="font-semibold text-slate-900">{{ client.client_name }}</div>
<div class="mt-1 text-xs text-slate-500">{{ catalogue.service_name }} • {{ task.task_name }}</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-white px-2 py-1 font-semibold text-slate-600">{{ task.status.replace('_',' ').title() if task.status else 'Pending' }}</span>
{% if item.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 font-semibold text-red-700">Overdue</span>{% endif %}
</div>
<div class="mt-2 line-clamp-2 text-xs text-slate-600">{{ comment.message }}</div>
</a> </a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">No items in this column.</div>
{% endfor %}
</div>
</div>
{% endfor %} {% endfor %}
</section> </section>
{% else %} {% else %}
<div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No consultant-visible assigned work found.</div> <div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No consultant-visible engagements found.</div>
{% endif %}
{% if work_board.assigned_tasks %}
<section class="rounded-3xl border border-indigo-200 bg-indigo-50 p-4 shadow-soft">
<div class="flex items-center justify-between"><div><h4 class="font-semibold text-slate-900">Tasks Assigned Directly to Me</h4><p class="text-xs text-slate-600">Only these tasks expose detailed execution controls.</p></div><span class="rounded-full bg-white px-2 py-1 text-xs font-semibold text-indigo-700">{{ work_board.assigned_tasks|length }}</span></div>
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">{% for item in work_board.assigned_tasks[:6] %}<a href="/consultant/assignments/{{ item.task.id }}" class="rounded-2xl border border-indigo-200 bg-white p-3 hover:bg-indigo-100"><div class="font-semibold text-slate-900">{{ item.client.client_name }}</div><div class="mt-1 text-xs text-slate-500">{{ item.task.task_name }}</div><div class="mt-2 text-xs font-semibold text-indigo-700">{{ item.task.consultant_assignment_status.replace('_',' ').title() }}</div></a>{% endfor %}</div>
</section>
{% endif %} {% endif %}
</div> </div>
@@ -4,83 +4,63 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 class="text-xl font-semibold text-slate-900">My Consultant Work Board</h2> <h2 class="text-xl font-semibold text-slate-900">Engagement Progress Report</h2>
<p class="text-sm text-slate-500">Consultant-visible work shared by the firm. Internal firm tasks and private notes are not shown here.</p> <p class="text-sm text-slate-500">Overall progress, current stage and external blockers for clients linked to you. Internal task allocation and private review notes are not shown.</p>
</div> </div>
<a href="/consultant/service-requests/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Raise Service Request</a> <a href="/consultant/service-requests/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Raise Service Request</a>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Engagements</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ board.total_engagements }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Average Progress</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ board.average_progress }}%</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">With Blockers</div><div class="mt-2 text-2xl font-bold text-amber-700">{{ board.blocked_engagements }}</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Overdue</div><div class="mt-2 text-2xl font-bold text-red-700">{{ board.overdue_engagements }}</div></div>
</div>
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"> <form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]"> <div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task, message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"> <input name="q" value="{{ q or '' }}" placeholder="Search client, engagement, period, stage or blocker" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"> <select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All columns</option> <option value="">All stages</option>
{% for code, label in board.column_options %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %} {% for code, label in board.stage_options %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select> </select>
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button> <button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
</div> </div>
</form> </form>
<div class="overflow-x-auto pb-2"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="grid min-w-[1180px] gap-4 lg:grid-cols-6"> <div class="overflow-x-auto">
{% for key, column in board.columns.items() %} <table class="min-w-full divide-y divide-slate-200 text-sm">
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-3"> <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<div class="mb-3 flex items-center justify-between"> <tr><th class="px-4 py-3">Client / Engagement</th><th class="px-4 py-3">Period</th><th class="min-w-[190px] px-4 py-3">Progress</th><th class="px-4 py-3">Current Stage</th><th class="min-w-[220px] px-4 py-3">Main Blocker</th><th class="px-4 py-3">Pending From</th><th class="px-4 py-3">Due Date</th></tr>
<h3 class="text-sm font-semibold text-slate-800">{{ column["label"] }}</h3> </thead>
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-semibold text-slate-600">{{ column["items"]|length }}</span> <tbody class="divide-y divide-slate-100">
</div> {% for item in board.engagements %}
<div class="space-y-3"> <tr class="hover:bg-slate-50">
{% for item in column["items"] %} <td class="px-4 py-4"><a href="/consultant/engagements/{{ item.subscription.id }}" class="font-semibold text-brand-700 hover:underline">{{ item.client.client_name }}</a><div class="mt-1 text-xs text-slate-500">{{ item.catalogue.service_name }}</div></td>
{% set task = item.task %} <td class="px-4 py-4 text-slate-700">{{ item.subscription.financial_year }}{% if item.subscription.assessment_year %}<div class="text-xs text-slate-500">AY {{ item.subscription.assessment_year }}</div>{% endif %}</td>
{% set client = item.client %} <td class="px-4 py-4"><div class="flex items-center justify-between text-xs font-semibold text-slate-700"><span>{{ item.progress_percentage }}%</span><span>{{ item.completed_tasks }}/{{ item.total_tasks }}</span></div><div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ item.progress_percentage }}%"></div></div></td>
{% set catalogue = item.catalogue %} <td class="px-4 py-4"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ item.external_stage }}</span></td>
<a href="/work/engagements/{{ task.subscription_id }}" class="block rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-300 hover:shadow-soft"> <td class="px-4 py-4"><div class="font-medium {% if item.blocker_code != 'none' %}text-amber-800{% else %}text-emerald-700{% endif %}">{{ item.blocker_text }}</div>{% if item.last_update_at %}<div class="mt-1 text-xs text-slate-500">Updated {{ item.last_update_at.strftime('%d-%m-%Y') }}</div>{% endif %}</td>
<div class="flex items-start justify-between gap-2"> <td class="px-4 py-4 text-slate-700">{{ item.pending_from }}</td>
<div> <td class="px-4 py-4 {% if item.is_overdue %}font-semibold text-red-700{% else %}text-slate-700{% endif %}">{{ item.due_date.strftime('%d-%m-%Y') if item.due_date else '—' }}{% if item.is_overdue %}<div class="text-xs">Overdue</div>{% endif %}</td>
<div class="text-sm font-semibold text-slate-900">{{ client.client_name }}</div> </tr>
<div class="text-xs text-slate-500">{{ catalogue.service_name }}</div> {% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No consultant-visible engagements found.</td></tr>{% endfor %}
</div> </tbody>
{% if item.is_overdue %}<span class="rounded-full bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% endif %} </table>
</div>
<div class="mt-3 text-sm font-medium text-slate-800">{{ task.task_name }}</div>
<div class="mt-2 flex flex-wrap gap-1 text-[11px] font-semibold">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.status.replace('_',' ').title() }}</span>
{% if task.execution_mode == 'consultant' and task.assigned_consultant_id == consultant.id %}<span class="rounded-full bg-indigo-50 px-2 py-1 text-indigo-700">Execution: {{ task.consultant_assignment_status.replace('_',' ').title() }}</span>{% endif %}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.priority.replace('_',' ').title() }}</span>
{% if task.internal_target_date %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %}
</div>
{% if item.comment %}<div class="mt-3 line-clamp-3 rounded-xl bg-slate-50 p-2 text-xs text-slate-600">{{ item.comment.message }}</div>{% else %}<div class="mt-3 rounded-xl bg-blue-50 p-2 text-xs text-blue-700">Visible through your active client/service link.</div>{% endif %}
</a>
{% else %}
<div class="rounded-xl border border-dashed border-slate-300 bg-white p-4 text-center text-xs text-slate-500">No items</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div> </div>
</div> </div>
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft"> <section class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3"> <div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Tasks Assigned Directly to Me</h3><p class="text-xs text-slate-500">Detailed task execution is available only for tasks specifically assigned to your consultant profile.</p></div>
<div>
<h3 class="font-semibold text-slate-900">My Service Requests</h3>
<p class="text-xs text-slate-500">Requests raised by you to the firm.</p>
</div>
<a href="/consultant/service-requests" class="text-xs font-semibold text-brand-700 hover:underline">View all</a>
</div>
<div class="divide-y divide-slate-100"> <div class="divide-y divide-slate-100">
{% for row in board.service_requests[:8] %} {% for item in board.assigned_tasks %}<a href="/consultant/assignments/{{ item.task.id }}" class="flex flex-wrap items-center justify-between gap-3 p-4 hover:bg-slate-50"><div><div class="font-semibold text-slate-900">{{ item.client.client_name }} — {{ item.task.task_name }}</div><div class="text-xs text-slate-500">{{ item.catalogue.service_name }} • {{ item.task.consultant_assignment_status.replace('_',' ').title() }}</div></div><div class="text-right text-xs {% if item.is_overdue %}font-semibold text-red-700{% else %}text-slate-500{% endif %}">{% if item.task.consultant_due_date %}Due {{ item.task.consultant_due_date.strftime('%d-%m-%Y') }}{% else %}No consultant due date{% endif %}</div></a>{% else %}<div class="p-6 text-sm text-slate-500">No task is directly assigned to you.</div>{% endfor %}
<a href="/consultant/service-requests/{{ row.id }}" class="flex flex-wrap items-center justify-between gap-3 p-4 hover:bg-slate-50">
<div>
<div class="font-semibold text-slate-900">{{ row.subject }}</div>
<div class="text-xs text-slate-500">{{ row.request_no }} • {{ row.requested_service_name }}{% if row.requested_due_date %} • Due {{ row.requested_due_date.strftime('%d-%m-%Y') }}{% endif %}</div>
</div>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ row.status.replace('_',' ').title() }}</span>
</a>
{% else %}
<div class="p-6 text-sm text-slate-500">No service requests yet.</div>
{% endfor %}
</div> </div>
</section>
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3"><div><h3 class="font-semibold text-slate-900">My Service Requests</h3><p class="text-xs text-slate-500">Requests raised by you to the firm.</p></div><a href="/consultant/service-requests" class="text-xs font-semibold text-brand-700 hover:underline">View all</a></div>
<div class="divide-y divide-slate-100">{% for row in board.service_requests[:8] %}<a href="/consultant/service-requests/{{ row.id }}" class="flex flex-wrap items-center justify-between gap-3 p-4 hover:bg-slate-50"><div><div class="font-semibold text-slate-900">{{ row.subject }}</div><div class="text-xs text-slate-500">{{ row.request_no }} • {{ row.requested_service_name }}</div></div><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ row.status.replace('_',' ').title() }}</span></a>{% else %}<div class="p-6 text-sm text-slate-500">No service requests yet.</div>{% endfor %}</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+29
View File
@@ -62,6 +62,7 @@ from app.modules.consultants.service import (
) )
from app.modules.consultants.portal_service import ( from app.modules.consultants.portal_service import (
get_consultant_assignment_detail, get_consultant_assignment_detail,
get_consultant_engagement_progress,
update_consultant_assignment, update_consultant_assignment,
get_consultant_document_centre, get_consultant_document_centre,
get_consultant_work_board, get_consultant_work_board,
@@ -1743,6 +1744,34 @@ def consultant_work_board(request: Request, q: str = "", status: str = ""):
db.close() db.close()
@portal_router.get("/engagements/{subscription_id}", response_class=HTMLResponse)
def consultant_engagement_progress(request: Request, subscription_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)):
return _redirect_denied()
consultant = _get_own_consultant_or_dashboard(request, db, user)
if not consultant:
return RedirectResponse(url="/consultant/dashboard", status_code=303)
progress = get_consultant_engagement_progress(db, consultant=consultant, subscription_id=subscription_id)
if not progress:
return RedirectResponse(url="/consultant/work", status_code=303)
return _render(
request,
"modules/consultants/templates/consultants/engagement_progress.html",
db,
user,
title="Engagement Progress",
consultant=consultant,
**progress,
)
finally:
db.close()
@portal_router.get("/assignments/{task_id}") @portal_router.get("/assignments/{task_id}")
def consultant_assignment_detail(request: Request, task_id: int): def consultant_assignment_detail(request: Request, task_id: int):
db = CommonSessionLocal() db = CommonSessionLocal()