diff --git a/app/modules/manager_dashboard/__init__.py b/app/modules/manager_dashboard/__init__.py new file mode 100644 index 0000000..2832114 --- /dev/null +++ b/app/modules/manager_dashboard/__init__.py @@ -0,0 +1 @@ +"""Manager Dashboard V1 module.""" diff --git a/app/modules/manager_dashboard/service.py b/app/modules/manager_dashboard/service.py new file mode 100644 index 0000000..957694a --- /dev/null +++ b/app/modules/manager_dashboard/service.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.modules.clients.models import Client +from app.modules.core.iam.models import User +from app.modules.core.rbac.models import Role, UserRole +from app.modules.employees.service import build_employee_scope, list_employee_work_assignable_users +from app.modules.services.execution import CLOSED_TASK_STATUSES +from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment + +MANAGER_ROLES = {"Manager", "Branch Manager", "System Admin", "Firm Admin", "Partner"} +MANAGER_PERMISSIONS = {"employees.work.manage", "services.tasks.review", "services.tasks.assign"} +REVIEW_STATUSES = {"completed", "ready_review", "review_pending", "pending_review", "manager_review"} +CLIENT_PENDING_STATUSES = {"blocked", "client_pending", "waiting_client", "documents_pending"} + + +def _count(db: Session, stmt) -> int: + return int(db.execute(stmt).scalar() or 0) + + +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]: + # Lightweight best-effort permission lookup through the active roles already used by the ERP RBAC tables. + 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): + # Supports both comma separated and JSON-like textual storage without being destructive. + cleaned = value.replace("[", "").replace("]", "").replace('"', "").replace("'", "") + permissions.update(v.strip() for v in cleaned.split(",") if v.strip()) + return permissions + + +def can_access_manager_dashboard(db: Session, current_user) -> bool: + roles = set(get_user_role_names(db, current_user.id)) + if roles.intersection(MANAGER_ROLES): + return True + return bool(get_user_permission_names(db, current_user.id).intersection(MANAGER_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 _task_scope(stmt, scope, fy: str | None = None): + stmt = stmt.where( + ClientServiceTaskInstance.tenant_id == scope.tenant_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) + return stmt + + +def _client_scope(stmt, scope): + stmt = stmt.where(Client.tenant_id == scope.tenant_id, Client.is_active.is_(True)) + if getattr(scope, "branch_id", None) is not None: + stmt = stmt.where(Client.branch_id == scope.branch_id) + return stmt + + +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 _task_href(task: ClientServiceTaskInstance) -> str: + task_id = getattr(task, "id", None) + if task_id: + return f"/employees/work/tasks/{task_id}/communication" + return "/manager/work" + + +def _task_row(task: ClientServiceTaskInstance) -> dict[str, Any]: + today = date.today() + client = getattr(task, "client", None) + catalogue = getattr(task, "catalogue", None) + subscription = getattr(task, "subscription", None) + assignee = getattr(task, "assigned_to", None) + status = (getattr(task, "status", None) or "pending").strip().lower() + due_date = getattr(task, "internal_target_date", None) + service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) + if not service_name and subscription is not None: + sub_catalogue = getattr(subscription, "catalogue", None) + service_name = getattr(sub_catalogue, "service_name", None) or getattr(sub_catalogue, "name", None) + return { + "id": getattr(task, "id", None), + "subscription_id": getattr(task, "subscription_id", None), + "client_name": getattr(client, "client_name", None) or "Unlinked Client", + "client_code": getattr(client, "client_code", None) or "", + "service_name": service_name or "Service", + "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", + "assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned", + "comment_count": len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]), + "is_overdue": bool(due_date and due_date < today and _is_open(status)), + "is_due_today": bool(due_date and due_date == today and _is_open(status)), + "href": _task_href(task), + } + + +def _load_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), + ) + ) + stmt = _task_scope(stmt, scope, fy) + stmt = stmt.order_by( + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.priority.desc(), + ClientServiceTaskInstance.id.desc(), + ).limit(350) + return list(db.execute(stmt).scalars().all()) + + +def _staff_rows(db: Session, scope, task_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + try: + users = list_employee_work_assignable_users(db, scope) + except Exception: + stmt = select(User).where(User.tenant_id == scope.tenant_id, User.is_active.is_(True)) + if getattr(scope, "branch_id", None) is not None: + stmt = stmt.where(User.branch_id == scope.branch_id) + users = list(db.execute(stmt.order_by(User.full_name.asc(), User.email.asc()).limit(100)).scalars().all()) + + stats_by_name: dict[str, dict[str, int]] = {} + for row in task_rows: + name = row.get("assigned_to") or "Unassigned" + bucket = stats_by_name.setdefault(name, {"active": 0, "overdue": 0, "review": 0, "client_pending": 0, "due_today": 0}) + if _is_open(row.get("status")): + bucket["active"] += 1 + if row.get("is_overdue"): + bucket["overdue"] += 1 + if row.get("is_due_today"): + bucket["due_today"] += 1 + if row.get("status") in REVIEW_STATUSES: + bucket["review"] += 1 + if row.get("status") in CLIENT_PENDING_STATUSES: + bucket["client_pending"] += 1 + + rows: list[dict[str, Any]] = [] + seen = set() + for user in users: + name = getattr(user, "full_name", None) or getattr(user, "email", None) or "User" + seen.add(name) + stats = stats_by_name.get(name, {"active": 0, "overdue": 0, "review": 0, "client_pending": 0, "due_today": 0}) + total = stats["active"] + stats["review"] + rows.append({ + "name": name, + "email": getattr(user, "email", None) or "", + "designation": getattr(user, "designation", None) or "", + **stats, + "load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"), + }) + if "Unassigned" in stats_by_name and "Unassigned" not in seen: + stats = stats_by_name["Unassigned"] + rows.append({"name": "Unassigned", "email": "", "designation": "Allocation pending", **stats, "load_status": "Needs allocation"}) + rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"], r["due_today"]), reverse=True) + return rows[:30] + + +def _client_rows(db: Session, scope, task_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + clients = db.execute(_client_scope(select(Client), scope).order_by(Client.client_name.asc()).limit(100)).scalars().all() + by_client: dict[str, dict[str, int]] = {} + for row in task_rows: + key = row["client_name"] + bucket = by_client.setdefault(key, {"open": 0, "overdue": 0, "client_pending": 0, "review": 0}) + if _is_open(row.get("status")): + bucket["open"] += 1 + if row.get("is_overdue"): + bucket["overdue"] += 1 + if row.get("status") in CLIENT_PENDING_STATUSES: + bucket["client_pending"] += 1 + if row.get("status") in REVIEW_STATUSES: + bucket["review"] += 1 + out: list[dict[str, Any]] = [] + for client in clients: + name = getattr(client, "client_name", None) or "Client" + stats = by_client.get(name, {"open": 0, "overdue": 0, "client_pending": 0, "review": 0}) + out.append({ + "id": getattr(client, "id", None), + "client_name": name, + "client_code": getattr(client, "client_code", None) or "", + "pan": getattr(client, "pan", None) or "", + "gstin": getattr(client, "gstin", None) or "", + **stats, + "href": f"/clients/{getattr(client, 'id', '')}/edit" if getattr(client, "id", None) else "/clients", + }) + return out[:50] + + +def _report_cards() -> list[dict[str, str]]: + return [ + {"group": "Execution", "title": "Team Work Status", "desc": "Open, overdue, in-progress and blocked assignments for the active branch/team.", "href": "/manager/dashboard?tab=team-work"}, + {"group": "Execution", "title": "Due Today / This Week", "desc": "Work requiring immediate attention and allocation follow-up.", "href": "/manager/dashboard?tab=team-work"}, + {"group": "Review", "title": "Manager Review Queue", "desc": "Completed or review-ready work waiting for manager action.", "href": "/manager/dashboard?tab=review-queue"}, + {"group": "Client Follow-up", "title": "Client Pending Report", "desc": "Tasks blocked due to documents, clarification or client data pending.", "href": "/manager/dashboard?tab=client-pending"}, + {"group": "Team", "title": "Staff Workload Report", "desc": "Staff-wise active, overdue, review and client-pending workload.", "href": "/manager/dashboard?tab=team-work"}, + {"group": "Documents", "title": "Document Checklist Report", "desc": "Document-related blocked tasks and communication timeline shortcuts.", "href": "/manager/dashboard?tab=documents"}, + ] + + +def _wizard_cards() -> list[dict[str, str]]: + return [ + {"title": "Task Assignment Wizard", "desc": "Open the detailed allocation board to assign or reassign staff.", "href": "/manager/work"}, + {"title": "Review Wizard", "desc": "Review completed work and send corrections through the task timeline.", "href": "/manager/dashboard?tab=review-queue"}, + {"title": "Client Query Wizard", "desc": "Handle client-pending tasks and record clarification requirements.", "href": "/manager/dashboard?tab=client-pending"}, + {"title": "Document Checklist Wizard", "desc": "Verify pending documents and open engagement document storage.", "href": "/documents"}, + {"title": "Work Closure Wizard", "desc": "Open engagement progress and close work after review completion.", "href": "/employees/progress"}, + {"title": "Alert / Follow-up Wizard", "desc": "Open alerts for reminders, escalations and follow-up actions.", "href": "/alerts"}, + ] + + +def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]: + scope = _scope(db, request, current_user) + fy = _active_financial_year(request) + today = date.today() + next_week = today + timedelta(days=7) + tasks = _load_tasks(db, scope, fy) + task_rows = [_task_row(t) for t in tasks] + open_rows = [r for r in task_rows if _is_open(r["status"])] + overdue_rows = [r for r in open_rows if r["is_overdue"]] + due_today_rows = [r for r in open_rows if r["is_due_today"]] + due_week_rows = [r for r in open_rows if r["due_date"] and today <= r["due_date"] <= next_week] + review_rows = [r for r in task_rows if r["status"] in REVIEW_STATUSES] + client_pending_rows = [r for r in open_rows if r["status"] in CLIENT_PENDING_STATUSES] + unassigned_rows = [r for r in open_rows if (r.get("assigned_to") or "Unassigned") == "Unassigned"] + in_progress_rows = [r for r in open_rows if r["status"] == "in_progress"] + + staff_rows = _staff_rows(db, scope, task_rows) + client_rows = _client_rows(db, scope, task_rows) + + tenant = getattr(scope, "tenant", None) + branch = getattr(scope, "branch", None) + overview = { + "tenant": tenant, + "branch": branch, + "tenant_id": getattr(scope, "tenant_id", None), + "branch_id": getattr(scope, "branch_id", None), + "financial_year": fy, + "total_tasks": len(task_rows), + "open_task_count": len(open_rows), + "unassigned_count": len(unassigned_rows), + "in_progress_count": len(in_progress_rows), + "overdue_count": len(overdue_rows), + "due_today_count": len(due_today_rows), + "due_week_count": len(due_week_rows), + "client_pending_count": len(client_pending_rows), + "review_pending_count": len(review_rows), + "staff_count": len(staff_rows), + "client_count": len(client_rows), + "today": today, + } + + return { + "roles": sorted(get_user_role_names(db, current_user.id)), + "overview": overview, + "team_work": task_rows[:100], + "due_today": due_today_rows[:30], + "due_week": due_week_rows[:30], + "overdue": overdue_rows[:30], + "unassigned": unassigned_rows[:30], + "in_progress": in_progress_rows[:30], + "review_queue": review_rows[:60], + "client_pending": client_pending_rows[:60], + "documents_pending": client_pending_rows[:40], + "staff_rows": staff_rows, + "clients": client_rows, + "reports": _report_cards(), + "wizards": _wizard_cards(), + } diff --git a/app/modules/manager_dashboard/templates/manager_dashboard/dashboard.html b/app/modules/manager_dashboard/templates/manager_dashboard/dashboard.html new file mode 100644 index 0000000..a9a217e --- /dev/null +++ b/app/modules/manager_dashboard/templates/manager_dashboard/dashboard.html @@ -0,0 +1,69 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Manager Execution Control
+Control team allocation, review queue, client pending items, documents and execution reports for the active branch/team.
+FY: {{ overview.financial_year or 'All' }}{% if overview.branch %} · Branch: {{ overview.branch.name or overview.branch.code }}{% endif %}
+| Client | +Task | +Service / Period | +Due | +Assigned | +Status | +Action | +
|---|---|---|---|---|---|---|
{{ row.client_name }} {{ row.client_code or '-' }} |
+ {{ row.task_name }} {{ row.comment_count }} timeline item(s) |
+ {{ row.service_name }} {{ row.period }} |
+ {{ row.due_date or '-' }} | +{{ row.assigned_to }} | +{{ row.status_label }} | +Open | +
| No records found for this view. | ||||||
Tasks delayed due to client documents, data or clarifications.
| Client | Open | Client Pending | Overdue | Action |
|---|---|---|---|---|
| {{ c.client_name }} | {{ c.open }} | {{ c.client_pending }} | {{ c.overdue }} | Open |
| No client pending clients found. | ||||
Document-related blocked tasks and timeline shortcuts.
Unassigned, overdue and client-pending work.
Action reports for team execution, review, client pending and documents.
{{ card.desc }}
Completed or review-ready work waiting for manager action.
Top 100 visible tasks. Use detailed board for assignment changes.
Guided shortcuts for assignment, review, client query, document checklist and closure.
{{ card.desc }}