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

+

Staff Dashboard V1

+

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 %}

+
+ +
+
+ + {% set tabs = [ + ('my-tasks','My Tasks'), + ('due-today','Due Today'), + ('overdue','Overdue'), + ('client-pending','Client Pending'), + ('documents','Documents'), + ('returned-work','Returned Work'), + ('reports','Reports'), + ('wizards','Wizards') + ] %} + +
+
+ {% for code, label in tabs %} + + {% endfor %} +
+
+ +
+ {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/' ~ (active_tab|replace('-', '_')) ~ '.html' ignore missing %} +
+
+ + +{% endblock %} diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html new file mode 100644 index 0000000..af88881 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html @@ -0,0 +1,46 @@ +
+
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientService / TaskDueStatusPriorityDocs / NotesAction
+
{{ 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.
+
+
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/client_pending.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/client_pending.html new file mode 100644 index 0000000..d221c1f --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/client_pending.html @@ -0,0 +1,5 @@ +
+

Client Pending

Work blocked due to client data, document or clarification pending.

+ {% set rows = client_pending %} + {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %} +
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/documents.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/documents.html new file mode 100644 index 0000000..19434e7 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/documents.html @@ -0,0 +1,8 @@ +
+
+

Documents

Document-linked or document-pending tasks assigned to you.

+ Open Documents +
+ {% set rows = documents_pending %} + {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %} +
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/due_today.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/due_today.html new file mode 100644 index 0000000..260b750 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/due_today.html @@ -0,0 +1,5 @@ +
+

Due Today

Tasks assigned to you with internal target date today.

+ {% set rows = due_today %} + {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %} +
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/my_tasks.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/my_tasks.html new file mode 100644 index 0000000..88efad6 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/my_tasks.html @@ -0,0 +1,10 @@ +
+
+

Open Tasks

{{ overview.open_count }}

+

Due Today

{{ overview.due_today_count }}

+

Overdue

{{ overview.overdue_count }}

+

In Progress

{{ overview.in_progress_count }}

+
+ {% set rows = my_tasks %} + {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %} +
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/overdue.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/overdue.html new file mode 100644 index 0000000..0bd91da --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/overdue.html @@ -0,0 +1,5 @@ +
+

Overdue Tasks

Assigned open tasks past internal target date.

+ {% set rows = overdue %} + {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %} +
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/reports.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/reports.html new file mode 100644 index 0000000..96189c6 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/reports.html @@ -0,0 +1,13 @@ +
+

Staff Reports

Quick report cards based on your assigned work.

+
+ {% for card in reports %} + +

{{ card.group }}

+

{{ card.title }}

+

{{ card.desc }}

+

View report →

+
+ {% endfor %} +
+
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/returned_work.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/returned_work.html new file mode 100644 index 0000000..61f4c02 --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/returned_work.html @@ -0,0 +1,5 @@ +
+

Returned / Correction Work

Tasks returned for correction, rework or resubmission.

+ {% set rows = returned_work %} + {% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %} +
diff --git a/app/modules/staff_dashboard/templates/staff_dashboard/partials/wizards.html b/app/modules/staff_dashboard/templates/staff_dashboard/partials/wizards.html new file mode 100644 index 0000000..516b19f --- /dev/null +++ b/app/modules/staff_dashboard/templates/staff_dashboard/partials/wizards.html @@ -0,0 +1,12 @@ +
+

Staff Work Wizards

Action shortcuts for task execution using existing ERP screens.

+
+ {% for card in wizards %} + +

{{ card.title }}

+

{{ card.desc }}

+

Open →

+
+ {% endfor %} +
+
diff --git a/app/modules/staff_dashboard/ui.py b/app/modules/staff_dashboard/ui.py new file mode 100644 index 0000000..b513b0e --- /dev/null +++ b/app/modules/staff_dashboard/ui.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from app.core.db.common import CommonSessionLocal +from app.core.http_responses import ui_access_denied +from app.core.security.csrf import get_or_create_csrf_token +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.staff_dashboard.service import build_staff_dashboard_payload, can_access_staff_dashboard + +router = APIRouter(prefix="/staff", tags=["staff-dashboard-v1-ui"]) + +VALID_TABS = { + "my-tasks": "modules/staff_dashboard/templates/staff_dashboard/partials/my_tasks.html", + "due-today": "modules/staff_dashboard/templates/staff_dashboard/partials/due_today.html", + "overdue": "modules/staff_dashboard/templates/staff_dashboard/partials/overdue.html", + "client-pending": "modules/staff_dashboard/templates/staff_dashboard/partials/client_pending.html", + "documents": "modules/staff_dashboard/templates/staff_dashboard/partials/documents.html", + "returned-work": "modules/staff_dashboard/templates/staff_dashboard/partials/returned_work.html", + "reports": "modules/staff_dashboard/templates/staff_dashboard/partials/reports.html", + "wizards": "modules/staff_dashboard/templates/staff_dashboard/partials/wizards.html", +} + + +def _ctx(request: Request, db, current_user, *, active_tab: str = "my-tasks"): + payload = build_staff_dashboard_payload(db, request, current_user) + return { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Staff Dashboard", + "active_tab": active_tab, + **payload, + } + + +@router.get("/dashboard") +def dashboard(request: Request, tab: str = "my-tasks"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + if not can_access_staff_dashboard(db, current_user): + return ui_access_denied() + active_tab = tab if tab in VALID_TABS else "my-tasks" + return templates.TemplateResponse( + "modules/staff_dashboard/templates/staff_dashboard/dashboard.html", + _ctx(request, db, current_user, active_tab=active_tab), + ) + finally: + db.close() + + +@router.get("/dashboard/tab/{tab_name}") +def dashboard_tab(request: Request, tab_name: str): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return RedirectResponse(url="/login", status_code=303) + if not can_access_staff_dashboard(db, current_user): + return ui_access_denied() + active_tab = tab_name if tab_name in VALID_TABS else "my-tasks" + return templates.TemplateResponse( + VALID_TABS[active_tab], + _ctx(request, db, current_user, active_tab=active_tab), + ) + finally: + db.close() diff --git a/app/ui/app.py b/app/ui/app.py index 751f667..abf89d0 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -3,6 +3,7 @@ from fastapi.staticfiles import StaticFiles from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router +from app.modules.staff_dashboard.ui import router as staff_dashboard_router from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router from app.modules.manager_dashboard.ui import router as manager_dashboard_router from app.modules.managers.ui import router as managers_ui_router @@ -54,6 +55,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(system_admin_dashboard_router) app.include_router(work_detail_ui_router) app.include_router(clients_ui_router) + app.include_router(staff_dashboard_router) app.include_router(employees_ui_router) app.include_router(manager_dashboard_router) app.include_router(managers_ui_router) @@ -66,3 +68,4 @@ def mount_ui(app: FastAPI) -> None: app.include_router(consultant_portal_router) +