Add staff dashboard and work wizards v1
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.employees.service import build_employee_scope, get_employee_for_user
|
||||
from app.modules.services.execution import CLOSED_TASK_STATUSES
|
||||
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment
|
||||
|
||||
STAFF_ROLES = {"Staff", "Branch Manager", "Manager", "System Admin", "Firm Admin", "Partner"}
|
||||
STAFF_PERMISSIONS = {"employees.work.view_self", "employees.ess.view", "services.tasks.update_self"}
|
||||
CLIENT_PENDING_STATUSES = {"blocked", "client_pending", "waiting_client", "documents_pending"}
|
||||
RETURNED_STATUSES = {"returned", "correction_required", "rework", "rejected"}
|
||||
REVIEW_STATUSES = {"completed", "ready_review", "review_pending", "pending_review", "manager_review"}
|
||||
|
||||
|
||||
def get_user_role_names(db: Session, user_id: int) -> list[str]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(Role.name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
|
||||
.order_by(Role.name.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def get_user_permission_names(db: Session, user_id: int) -> set[str]:
|
||||
rows = db.execute(
|
||||
select(Role.permissions)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
|
||||
).scalars().all()
|
||||
permissions: set[str] = set()
|
||||
for value in rows:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
permissions.update(str(v) for v in value if v)
|
||||
elif isinstance(value, str):
|
||||
cleaned = value.replace("[", "").replace("]", "").replace('"', "").replace("'", "")
|
||||
permissions.update(v.strip() for v in cleaned.split(",") if v.strip())
|
||||
return permissions
|
||||
|
||||
|
||||
def can_access_staff_dashboard(db: Session, current_user) -> bool:
|
||||
roles = set(get_user_role_names(db, current_user.id))
|
||||
if roles.intersection(STAFF_ROLES):
|
||||
return True
|
||||
if get_employee_for_user(db, current_user):
|
||||
return True
|
||||
return bool(get_user_permission_names(db, current_user.id).intersection(STAFF_PERMISSIONS))
|
||||
|
||||
|
||||
def _active_financial_year(request) -> str | None:
|
||||
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _scope(db: Session, request, current_user):
|
||||
return build_employee_scope(
|
||||
db,
|
||||
current_user,
|
||||
tenant_id=request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None),
|
||||
branch_id=request.session.get("active_branch_id"),
|
||||
)
|
||||
|
||||
|
||||
def _is_open(status: str | None) -> bool:
|
||||
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
|
||||
|
||||
|
||||
def _status_label(status: str | None) -> str:
|
||||
return (status or "pending").replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def _days_overdue(due_date) -> int:
|
||||
if not due_date:
|
||||
return 0
|
||||
delta = (date.today() - due_date).days
|
||||
return delta if delta > 0 else 0
|
||||
|
||||
|
||||
def _task_href(task: ClientServiceTaskInstance) -> str:
|
||||
task_id = getattr(task, "id", None)
|
||||
if task_id:
|
||||
return f"/employees/work/tasks/{task_id}/communication"
|
||||
return "/employees/work"
|
||||
|
||||
|
||||
def _service_name(task: ClientServiceTaskInstance) -> str:
|
||||
catalogue = getattr(task, "catalogue", None)
|
||||
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None)
|
||||
if not service_name:
|
||||
subscription = getattr(task, "subscription", None)
|
||||
sub_catalogue = getattr(subscription, "catalogue", None) if subscription else None
|
||||
service_name = getattr(sub_catalogue, "service_name", None) or getattr(sub_catalogue, "name", None)
|
||||
return service_name or "Service"
|
||||
|
||||
|
||||
def _task_row(task: ClientServiceTaskInstance) -> dict[str, Any]:
|
||||
today = date.today()
|
||||
client = getattr(task, "client", None)
|
||||
status = (getattr(task, "status", None) or "pending").strip().lower()
|
||||
due_date = getattr(task, "internal_target_date", None)
|
||||
is_open = _is_open(status)
|
||||
return {
|
||||
"id": getattr(task, "id", None),
|
||||
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
||||
"client_code": getattr(client, "client_code", None) or "",
|
||||
"service_name": _service_name(task),
|
||||
"task_name": getattr(task, "task_name", None) or "Task",
|
||||
"period": getattr(task, "financial_year", None) or "-",
|
||||
"due_date": due_date,
|
||||
"status": status,
|
||||
"status_label": _status_label(status),
|
||||
"priority": getattr(task, "priority", None) or "normal",
|
||||
"remarks": getattr(task, "remarks", None) or "",
|
||||
"comment_count": len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]),
|
||||
"document_count": len(getattr(task, "documents", []) or []),
|
||||
"is_open": is_open,
|
||||
"is_overdue": bool(due_date and due_date < today and is_open),
|
||||
"is_due_today": bool(due_date and due_date == today and is_open),
|
||||
"is_due_week": bool(due_date and today <= due_date <= today + timedelta(days=7) and is_open),
|
||||
"days_overdue": _days_overdue(due_date) if is_open else 0,
|
||||
"href": _task_href(task),
|
||||
}
|
||||
|
||||
|
||||
def _load_my_tasks(db: Session, scope, fy: str | None) -> list[ClientServiceTaskInstance]:
|
||||
stmt = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.client),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.documents),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
|
||||
ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if getattr(scope, "branch_id", None) is not None:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
|
||||
if fy:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
|
||||
stmt = stmt.order_by(
|
||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||
ClientServiceTaskInstance.priority.desc(),
|
||||
ClientServiceTaskInstance.id.desc(),
|
||||
).limit(250)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def _report_cards() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"group": "My Work", "title": "My Pending Tasks", "desc": "All open work assigned to me.", "href": "/staff/dashboard?tab=my-tasks"},
|
||||
{"group": "Due Control", "title": "Due Today", "desc": "Tasks that must be acted on today.", "href": "/staff/dashboard?tab=due-today"},
|
||||
{"group": "Due Control", "title": "Overdue Tasks", "desc": "Assigned open work past internal target date.", "href": "/staff/dashboard?tab=overdue"},
|
||||
{"group": "Client Follow-up", "title": "Client Pending", "desc": "Tasks blocked due to client data, documents or clarification pending.", "href": "/staff/dashboard?tab=client-pending"},
|
||||
{"group": "Documents", "title": "Document Pending", "desc": "Assigned work requiring document upload or verification follow-up.", "href": "/staff/dashboard?tab=documents"},
|
||||
{"group": "Correction", "title": "Returned Work", "desc": "Work returned for correction or rework.", "href": "/staff/dashboard?tab=returned-work"},
|
||||
]
|
||||
|
||||
|
||||
def _wizard_cards() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"title": "My Task Work Wizard", "desc": "Open assigned task, read instructions, update status and add work notes.", "href": "/employees/work"},
|
||||
{"title": "Document Upload Wizard", "desc": "Open document area for uploading supporting records and work papers.", "href": "/documents"},
|
||||
{"title": "Mark Client Pending Wizard", "desc": "Use task communication to record document or clarification pending from client.", "href": "/staff/dashboard?tab=client-pending"},
|
||||
{"title": "Submit for Review Wizard", "desc": "Open completed tasks and submit them through existing task communication workflow.", "href": "/staff/dashboard?tab=my-tasks"},
|
||||
{"title": "Time / Remarks Wizard", "desc": "Update remarks and progress on current assigned task using existing work screen.", "href": "/employees/work"},
|
||||
{"title": "Correction / Returned Work Wizard", "desc": "Open returned tasks and complete rework before resubmission.", "href": "/staff/dashboard?tab=returned-work"},
|
||||
]
|
||||
|
||||
|
||||
def build_staff_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
|
||||
scope = _scope(db, request, current_user)
|
||||
fy = _active_financial_year(request)
|
||||
today = date.today()
|
||||
tasks = _load_my_tasks(db, scope, fy)
|
||||
task_rows = [_task_row(t) for t in tasks]
|
||||
open_rows = [r for r in task_rows if r["is_open"]]
|
||||
completed_rows = [r for r in task_rows if not r["is_open"]]
|
||||
due_today_rows = [r for r in open_rows if r["is_due_today"]]
|
||||
overdue_rows = [r for r in open_rows if r["is_overdue"]]
|
||||
due_week_rows = [r for r in open_rows if r["is_due_week"]]
|
||||
client_pending_rows = [r for r in open_rows if r["status"] in CLIENT_PENDING_STATUSES]
|
||||
returned_rows = [r for r in open_rows if r["status"] in RETURNED_STATUSES]
|
||||
documents_rows = [r for r in open_rows if r["document_count"] > 0 or r["status"] in CLIENT_PENDING_STATUSES]
|
||||
review_rows = [r for r in task_rows if r["status"] in REVIEW_STATUSES]
|
||||
in_progress_rows = [r for r in open_rows if r["status"] == "in_progress"]
|
||||
|
||||
status_summary: dict[str, int] = {}
|
||||
service_summary: dict[str, int] = {}
|
||||
for row in open_rows:
|
||||
status_summary[row["status_label"]] = status_summary.get(row["status_label"], 0) + 1
|
||||
service_summary[row["service_name"]] = service_summary.get(row["service_name"], 0) + 1
|
||||
|
||||
employee = get_employee_for_user(db, current_user)
|
||||
overview = {
|
||||
"tenant_id": getattr(scope, "tenant_id", None),
|
||||
"branch_id": getattr(scope, "branch_id", None),
|
||||
"financial_year": fy,
|
||||
"employee": employee,
|
||||
"today": today,
|
||||
"total_tasks": len(task_rows),
|
||||
"open_count": len(open_rows),
|
||||
"in_progress_count": len(in_progress_rows),
|
||||
"due_today_count": len(due_today_rows),
|
||||
"due_week_count": len(due_week_rows),
|
||||
"overdue_count": len(overdue_rows),
|
||||
"client_pending_count": len(client_pending_rows),
|
||||
"documents_count": len(documents_rows),
|
||||
"returned_count": len(returned_rows),
|
||||
"review_count": len(review_rows),
|
||||
"completed_count": len(completed_rows),
|
||||
}
|
||||
|
||||
return {
|
||||
"roles": sorted(get_user_role_names(db, current_user.id)),
|
||||
"overview": overview,
|
||||
"my_tasks": open_rows[:100],
|
||||
"due_today": due_today_rows[:50],
|
||||
"due_week": due_week_rows[:50],
|
||||
"overdue": overdue_rows[:50],
|
||||
"client_pending": client_pending_rows[:50],
|
||||
"documents_pending": documents_rows[:50],
|
||||
"returned_work": returned_rows[:50],
|
||||
"completed": completed_rows[:50],
|
||||
"review_ready": review_rows[:50],
|
||||
"status_summary": sorted([{"label": k, "count": v} for k, v in status_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
|
||||
"service_summary": sorted([{"label": k, "count": v} for k, v in service_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
|
||||
"reports": _report_cards(),
|
||||
"wizards": _wizard_cards(),
|
||||
}
|
||||
Reference in New Issue
Block a user