diff --git a/app/modules/staff_dashboard/__init__.py b/app/modules/staff_dashboard/__init__.py new file mode 100644 index 0000000..dcc4188 --- /dev/null +++ b/app/modules/staff_dashboard/__init__.py @@ -0,0 +1 @@ +"""Staff dashboard module.""" diff --git a/app/modules/staff_dashboard/service.py b/app/modules/staff_dashboard/service.py new file mode 100644 index 0000000..6398c89 --- /dev/null +++ b/app/modules/staff_dashboard/service.py @@ -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(), + } diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/dashboard.html b/app/modules/staff_dashboard/templates/staff_dashboard/dashboard.html new file mode 100644 index 0000000..f833927 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/dashboard.html @@ -0,0 +1,70 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Staff Work Execution
+Your assigned tasks, due work, client-pending items, documents, returned work and staff work wizards in one place.
+FY: {{ overview.financial_year or 'All' }}{% if overview.employee %} · {{ overview.employee.full_name }}{% endif %}
+| Client | +Service / Task | +Due | +Status | +Priority | +Docs / Notes | +Action | +
|---|---|---|---|---|---|---|
|
+ {{ row.client_name }}
+ {{ row.client_code or '-' }}
+ |
+
+ {{ row.service_name }}
+ {{ row.task_name }}
+ Period: {{ row.period }}
+ |
+
+ {% if row.due_date %}
+ {{ row.due_date.strftime('%d-%b-%Y') }}
+ {% if row.is_overdue %}{{ row.days_overdue }} day(s) overdue {% endif %}
+ {% else %}
+ No due date
+ {% endif %}
+ |
+ {{ row.status_label }} | +{{ row.priority|title }} | +Docs: {{ row.document_count }} Notes: {{ row.comment_count }} |
+ Open | +
| No tasks found for this view. | ||||||
Work blocked due to client data, document or clarification pending.
Document-linked or document-pending tasks assigned to you.
Tasks assigned to you with internal target date today.
Open Tasks
{{ overview.open_count }}
Due Today
{{ overview.due_today_count }}
Overdue
{{ overview.overdue_count }}
In Progress
{{ overview.in_progress_count }}
Assigned open tasks past internal target date.
Quick report cards based on your assigned work.
{{ card.group }}
+{{ card.desc }}
+View report →
+ + {% endfor %} +Tasks returned for correction, rework or resubmission.
Action shortcuts for task execution using existing ERP screens.
{{ card.desc }}
+Open →
+ + {% endfor %} +