diff --git a/app/modules/consultants/portal_service.py b/app/modules/consultants/portal_service.py index d8c2e94..863f1a1 100644 --- a/app/modules/consultants/portal_service.py +++ b/app/modules/consultants/portal_service.py @@ -15,6 +15,7 @@ from app.modules.services.models import ( ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment, + ServiceTaskDocumentRequest, ) 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) +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: - 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]] = {} for link in links: 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: - 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() - for task, subscription, client, catalogue in rows: - allowed=next((ln for ln in links_by_client[int(client.id)] if ln.service_catalogue_id in (None, task.service_catalogue_id)),None) - if not allowed or not _matches_search(client.client_name, getattr(client,"client_code",""), catalogue.service_name, task.task_name, q=q): continue - key=_board_key_for_status(task.status) - if status and key!=status: continue - 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() - 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 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: - client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False) - if not client_ids: - return None row = db.execute( select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) .join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id) @@ -95,15 +402,25 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi .where( ClientServiceTaskInstance.id == task_id, ClientServiceTaskInstance.tenant_id == consultant.tenant_id, - ClientServiceTaskInstance.client_id.in_(client_ids), ClientServiceTaskInstance.is_active.is_(True), + ClientServiceTaskInstance.execution_mode == "consultant", + ClientServiceTaskInstance.assigned_consultant_id == consultant.id, ) ).first() if not row: return None 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() - if not link: + 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() + if not link or not _effective_link(link, date.today()): return None timeline = db.execute( select(ServiceTaskComment) diff --git a/app/modules/consultants/templates/consultants/engagement_progress.html b/app/modules/consultants/templates/consultants/engagement_progress.html new file mode 100644 index 0000000..4820fa6 --- /dev/null +++ b/app/modules/consultants/templates/consultants/engagement_progress.html @@ -0,0 +1,22 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %} +
+

{{ client.client_name }} — {{ catalogue.service_name }}

Engagement progress report • {{ subscription.financial_year }}{% if subscription.assessment_year %} • AY {{ subscription.assessment_year }}{% endif %}

Back to Engagement Report
+ +
+
Overall Progress
{{ progress_percentage }}%
+
Current Stage
{{ external_stage }}
+
Pending From
{{ pending_from }}
+
Statutory / Target Due Date
{{ due_date.strftime('%d-%m-%Y') if due_date else 'Not set' }}
{% if is_overdue %}
Overdue
{% endif %}
+
+ +
Current Blocker
{{ blocker_text }}

Internal staff assignments, task statuses and private review remarks are intentionally not displayed.

+ +
+

Meaningful Engagement Updates

Only communications shared with the consultant are shown.

{% for note in communications %}
{{ note.comment_type.replace('_',' ').title() }}
{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}
{{ note.message }}
{% else %}
No consultant-visible update has been shared yet.
{% endfor %}
+ +
+
+{% endblock %} diff --git a/app/modules/consultants/templates/consultants/portal_partials/assigned_work.html b/app/modules/consultants/templates/consultants/portal_partials/assigned_work.html index 087a0d8..26271e5 100644 --- a/app/modules/consultants/templates/consultants/portal_partials/assigned_work.html +++ b/app/modules/consultants/templates/consultants/portal_partials/assigned_work.html @@ -2,42 +2,36 @@
-

Assigned Work

-

Consultant-visible assignments and firm task communications.

+

Engagement Progress

+

Overall engagement progress and external blockers. Detailed internal task status is not exposed.

- Open Full Work Board + Open Full Engagement Report
{% set work_board = payload.work_board if payload.work_board is defined else {} %} - {% if work_board.columns %} + {% if work_board.engagements %}
- {% for key, column in work_board.columns.items() %} -
{% else %} -
No consultant-visible assigned work found.
+
No consultant-visible engagements found.
+ {% endif %} + + {% if work_board.assigned_tasks %} +
+

Tasks Assigned Directly to Me

Only these tasks expose detailed execution controls.

{{ work_board.assigned_tasks|length }}
+ +
{% endif %} diff --git a/app/modules/consultants/templates/consultants/work_board.html b/app/modules/consultants/templates/consultants/work_board.html index 60230de..af59155 100644 --- a/app/modules/consultants/templates/consultants/work_board.html +++ b/app/modules/consultants/templates/consultants/work_board.html @@ -4,83 +4,63 @@
-

My Consultant Work Board

-

Consultant-visible work shared by the firm. Internal firm tasks and private notes are not shown here.

+

Engagement Progress Report

+

Overall progress, current stage and external blockers for clients linked to you. Internal task allocation and private review notes are not shown.

Raise Service Request
+
+
Engagements
{{ board.total_engagements }}
+
Average Progress
{{ board.average_progress }}%
+
With Blockers
{{ board.blocked_engagements }}
+
Overdue
{{ board.overdue_engagements }}
+
+
- +
-
-
- {% for key, column in board.columns.items() %} -
-
-

{{ column["label"] }}

- {{ column["items"]|length }} -
- -
- {% endfor %} +
+
+ + + + + + {% for item in board.engagements %} + + + + + + + + + + {% else %}{% endfor %} + +
Client / EngagementPeriodProgressCurrent StageMain BlockerPending FromDue Date
{{ item.client.client_name }}
{{ item.catalogue.service_name }}
{{ item.subscription.financial_year }}{% if item.subscription.assessment_year %}
AY {{ item.subscription.assessment_year }}
{% endif %}
{{ item.progress_percentage }}%{{ item.completed_tasks }}/{{ item.total_tasks }}
{{ item.external_stage }}
{{ item.blocker_text }}
{% if item.last_update_at %}
Updated {{ item.last_update_at.strftime('%d-%m-%Y') }}
{% endif %}
{{ item.pending_from }}{{ item.due_date.strftime('%d-%m-%Y') if item.due_date else '—' }}{% if item.is_overdue %}
Overdue
{% endif %}
No consultant-visible engagements found.
-
-
-
-

My Service Requests

-

Requests raised by you to the firm.

-
- View all -
+
+

Tasks Assigned Directly to Me

Detailed task execution is available only for tasks specifically assigned to your consultant profile.

+
+ +
+

My Service Requests

Requests raised by you to the firm.

View all
+
{% for row in board.service_requests[:8] %}
{{ row.subject }}
{{ row.request_no }} • {{ row.requested_service_name }}
{{ row.status.replace('_',' ').title() }}
{% else %}
No service requests yet.
{% endfor %}
{% endblock %} diff --git a/app/modules/consultants/ui.py b/app/modules/consultants/ui.py index eba7bef..5fdea8e 100644 --- a/app/modules/consultants/ui.py +++ b/app/modules/consultants/ui.py @@ -62,6 +62,7 @@ from app.modules.consultants.service import ( ) from app.modules.consultants.portal_service import ( get_consultant_assignment_detail, + get_consultant_engagement_progress, update_consultant_assignment, get_consultant_document_centre, get_consultant_work_board, @@ -1743,6 +1744,34 @@ def consultant_work_board(request: Request, q: str = "", status: str = ""): 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}") def consultant_assignment_detail(request: Request, task_id: int): db = CommonSessionLocal()