Show engagement progress instead of internal task status to consultants
This commit is contained in:
@@ -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">
|
</a>
|
||||||
<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>
|
|
||||||
{% 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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user