Add manager dashboard and wizards v1
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""Manager Dashboard V1 module."""
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-3xl bg-gradient-to-r from-slate-900 via-brand-700 to-brand-600 p-6 text-white shadow-soft">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-brand-100">Manager Execution Control</p>
|
||||||
|
<h1 class="mt-2 text-2xl font-bold">Manager Dashboard</h1>
|
||||||
|
<p class="mt-2 max-w-3xl text-sm text-brand-100">Control team allocation, review queue, client pending items, documents and execution reports for the active branch/team.</p>
|
||||||
|
<p class="mt-3 text-xs text-brand-100">FY: {{ overview.financial_year or 'All' }}{% if overview.branch %} · Branch: {{ overview.branch.name or overview.branch.code }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/manager/work" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open Work Board</a>
|
||||||
|
<a href="/employees/progress" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Progress</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% set tabs = [
|
||||||
|
('overview','Overview'),
|
||||||
|
('team-work','Team Work'),
|
||||||
|
('review-queue','Review Queue'),
|
||||||
|
('client-pending','Client Pending'),
|
||||||
|
('documents','Documents'),
|
||||||
|
('reports','Reports'),
|
||||||
|
('wizards','Wizards')
|
||||||
|
] %}
|
||||||
|
|
||||||
|
<section class="rounded-3xl border border-slate-200 bg-white p-3 shadow-soft">
|
||||||
|
<div class="flex gap-2 overflow-x-auto" id="manager-tabs">
|
||||||
|
{% for code, label in tabs %}
|
||||||
|
<button type="button" data-tab="{{ code }}" class="manager-tab whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-semibold transition {% if active_tab == code %}bg-brand-600 text-white{% else %}text-slate-600 hover:bg-slate-100{% endif %}">{{ label }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="manager-dashboard-panel">
|
||||||
|
{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/' ~ (active_tab|replace('-', '_')) ~ '.html' ignore missing %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const tabs = document.querySelectorAll('.manager-tab');
|
||||||
|
const panel = document.getElementById('manager-dashboard-panel');
|
||||||
|
async function loadTab(tab) {
|
||||||
|
tabs.forEach(btn => {
|
||||||
|
const active = btn.dataset.tab === tab;
|
||||||
|
btn.classList.toggle('bg-brand-600', active);
|
||||||
|
btn.classList.toggle('text-white', active);
|
||||||
|
btn.classList.toggle('text-slate-600', !active);
|
||||||
|
btn.classList.toggle('hover:bg-slate-100', !active);
|
||||||
|
});
|
||||||
|
panel.innerHTML = '<div class="rounded-3xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/manager/dashboard/tab/' + encodeURIComponent(tab), {headers: {'X-Requested-With':'fetch'}});
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
panel.innerHTML = await res.text();
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', tab);
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-8 text-center text-sm font-semibold text-red-700 shadow-soft">Unable to load tab. Please refresh the page.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tabs.forEach(btn => btn.addEventListener('click', () => loadTab(btn.dataset.tab)));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left">Client</th>
|
||||||
|
<th class="px-4 py-3 text-left">Task</th>
|
||||||
|
<th class="px-4 py-3 text-left">Service / Period</th>
|
||||||
|
<th class="px-4 py-3 text-left">Due</th>
|
||||||
|
<th class="px-4 py-3 text-left">Assigned</th>
|
||||||
|
<th class="px-4 py-3 text-left">Status</th>
|
||||||
|
<th class="px-4 py-3 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.client_code or '-' }}</div></td>
|
||||||
|
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.task_name }}</div><div class="text-xs text-slate-500">{{ row.comment_count }} timeline item(s)</div></td>
|
||||||
|
<td class="px-4 py-3"><div>{{ row.service_name }}</div><div class="text-xs text-slate-500">{{ row.period }}</div></td>
|
||||||
|
<td class="px-4 py-3"><span class="{% if row.is_overdue %}text-red-700 font-semibold{% elif row.is_due_today %}text-amber-700 font-semibold{% endif %}">{{ row.due_date or '-' }}</span></td>
|
||||||
|
<td class="px-4 py-3">{{ row.assigned_to }}</td>
|
||||||
|
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ row.status_label }}</span></td>
|
||||||
|
<td class="px-4 py-3 text-right"><a href="{{ row.href }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No records found for this view.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Client Pending / Blocked Work</h3><p class="text-sm text-slate-500">Tasks delayed due to client documents, data or clarifications.</p></div><a href="/alerts" class="af-btn af-btn-primary">Alerts</a></div>{% set rows = client_pending %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
|
||||||
|
<div class="af-card"><h3 class="mb-4 text-lg font-semibold text-slate-900">Client-wise Pending View</h3><div class="overflow-x-auto"><table class="min-w-full text-sm"><thead class="bg-slate-50 text-xs uppercase text-slate-500"><tr><th class="px-4 py-3 text-left">Client</th><th class="px-4 py-3 text-right">Open</th><th class="px-4 py-3 text-right">Client Pending</th><th class="px-4 py-3 text-right">Overdue</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody>{% for c in clients if c.client_pending or c.overdue %}<tr class="border-t"><td class="px-4 py-3 font-semibold">{{ c.client_name }}</td><td class="px-4 py-3 text-right">{{ c.open }}</td><td class="px-4 py-3 text-right">{{ c.client_pending }}</td><td class="px-4 py-3 text-right">{{ c.overdue }}</td><td class="px-4 py-3 text-right"><a href="{{ c.href }}" class="text-brand-700 font-semibold">Open</a></td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No client pending clients found.</td></tr>{% endfor %}</tbody></table></div></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Document Checklist Control</h3><p class="text-sm text-slate-500">Document-related blocked tasks and timeline shortcuts.</p></div><a href="/documents" class="af-btn af-btn-primary">Open Documents</a></div>{% set rows = documents_pending %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||||
|
<a href="/manager/dashboard?tab=team-work" class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Open Tasks</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ overview.open_task_count }}</div><div class="mt-1 text-xs text-slate-500">Visible team work</div></a>
|
||||||
|
<a href="/manager/dashboard?tab=team-work" class="af-metric-card border-orange-200 bg-orange-50"><div class="text-xs font-semibold uppercase text-orange-700">Unassigned</div><div class="mt-2 text-3xl font-semibold text-orange-700">{{ overview.unassigned_count }}</div><div class="mt-1 text-xs text-orange-700">Allocate first</div></a>
|
||||||
|
<a href="/manager/dashboard?tab=team-work" class="af-metric-card border-blue-200 bg-blue-50"><div class="text-xs font-semibold uppercase text-blue-700">In Progress</div><div class="mt-2 text-3xl font-semibold text-blue-700">{{ overview.in_progress_count }}</div><div class="mt-1 text-xs text-blue-700">Being worked</div></a>
|
||||||
|
<a href="/manager/dashboard?tab=client-pending" class="af-metric-card border-amber-200 bg-amber-50"><div class="text-xs font-semibold uppercase text-amber-700">Client Pending</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ overview.client_pending_count }}</div><div class="mt-1 text-xs text-amber-700">Needs follow-up</div></a>
|
||||||
|
<a href="/manager/dashboard?tab=review-queue" class="af-metric-card border-emerald-200 bg-emerald-50"><div class="text-xs font-semibold uppercase text-emerald-700">Review Queue</div><div class="mt-2 text-3xl font-semibold text-emerald-700">{{ overview.review_pending_count }}</div><div class="mt-1 text-xs text-emerald-700">Review-ready</div></a>
|
||||||
|
<a href="/manager/dashboard?tab=team-work" class="af-metric-card border-red-200 bg-red-50"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ overview.overdue_count }}</div><div class="mt-1 text-xs text-red-700">Escalate</div></a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<div class="af-card">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div><h3 class="text-lg font-semibold text-slate-900">Attention Required</h3><p class="text-sm text-slate-500">Unassigned, overdue and client-pending work.</p></div>
|
||||||
|
<a href="/manager/work" class="af-btn af-btn-primary">Open Work Board</a>
|
||||||
|
</div>
|
||||||
|
{% set rows = (unassigned + overdue + client_pending)[:12] %}
|
||||||
|
{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="af-card"><h3 class="font-semibold text-slate-900">Team Snapshot</h3><div class="mt-4 grid grid-cols-2 gap-3 text-sm"><div class="rounded-2xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Staff visible</div><div class="text-2xl font-semibold">{{ overview.staff_count }}</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Clients visible</div><div class="text-2xl font-semibold">{{ overview.client_count }}</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Due today</div><div class="text-2xl font-semibold">{{ overview.due_today_count }}</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-xs text-slate-500">Due week</div><div class="text-2xl font-semibold">{{ overview.due_week_count }}</div></div></div></div>
|
||||||
|
<div class="af-card"><h3 class="font-semibold text-slate-900">Quick Actions</h3><div class="mt-4 grid gap-2"><a href="/manager/work" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Task Assignment Wizard</a><a href="/manager/dashboard?tab=review-queue" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Review Wizard</a><a href="/manager/dashboard?tab=client-pending" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Client Query Wizard</a><a href="/documents" class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold hover:bg-slate-50">Document Checklist</a></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div><h3 class="text-lg font-semibold text-slate-900">Manager Reports</h3><p class="text-sm text-slate-500">Action reports for team execution, review, client pending and documents.</p></div>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for card in reports %}
|
||||||
|
<a href="{{ card.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200 hover:bg-brand-50/30"><div class="text-xs font-semibold uppercase tracking-wide text-brand-600">{{ card.group }}</div><h4 class="mt-2 font-semibold text-slate-900">{{ card.title }}</h4><p class="mt-2 text-sm text-slate-500">{{ card.desc }}</p><div class="mt-4 text-sm font-semibold text-brand-700">View Report →</div></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Manager Review Queue</h3><p class="text-sm text-slate-500">Completed or review-ready work waiting for manager action.</p></div><a href="/employees/progress" class="af-btn af-btn-primary">Progress</a></div>{% set rows = review_queue %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Total Visible</div><div class="mt-2 text-3xl font-semibold">{{ overview.total_tasks }}</div></div>
|
||||||
|
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-orange-700">Unassigned</div><div class="mt-2 text-3xl font-semibold text-orange-700">{{ overview.unassigned_count }}</div></div>
|
||||||
|
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-blue-700">In Progress</div><div class="mt-2 text-3xl font-semibold text-blue-700">{{ overview.in_progress_count }}</div></div>
|
||||||
|
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ overview.overdue_count }}</div></div>
|
||||||
|
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-amber-700">Due Today</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ overview.due_today_count }}</div></div>
|
||||||
|
</section>
|
||||||
|
<div class="af-card"><div class="mb-4 flex items-center justify-between"><div><h3 class="text-lg font-semibold text-slate-900">Team Work Board Summary</h3><p class="text-sm text-slate-500">Top 100 visible tasks. Use detailed board for assignment changes.</p></div><a href="/manager/work" class="af-btn af-btn-primary">Detailed Board</a></div>{% set rows = team_work %}{% include 'modules/manager_dashboard/templates/manager_dashboard/partials/_task_table.html' %}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div><h3 class="text-lg font-semibold text-slate-900">Manager Wizards</h3><p class="text-sm text-slate-500">Guided shortcuts for assignment, review, client query, document checklist and closure.</p></div>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for card in wizards %}
|
||||||
|
<a href="{{ card.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200 hover:bg-brand-50/30"><h4 class="font-semibold text-slate-900">{{ card.title }}</h4><p class="mt-2 text-sm text-slate-500">{{ card.desc }}</p><div class="mt-4 text-sm font-semibold text-brand-700">Open →</div></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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.manager_dashboard.service import build_manager_dashboard_payload, can_access_manager_dashboard
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/manager", tags=["manager-dashboard-v1-ui"])
|
||||||
|
|
||||||
|
VALID_TABS = {
|
||||||
|
"overview": "modules/manager_dashboard/templates/manager_dashboard/partials/overview.html",
|
||||||
|
"team-work": "modules/manager_dashboard/templates/manager_dashboard/partials/team_work.html",
|
||||||
|
"review-queue": "modules/manager_dashboard/templates/manager_dashboard/partials/review_queue.html",
|
||||||
|
"client-pending": "modules/manager_dashboard/templates/manager_dashboard/partials/client_pending.html",
|
||||||
|
"documents": "modules/manager_dashboard/templates/manager_dashboard/partials/documents.html",
|
||||||
|
"reports": "modules/manager_dashboard/templates/manager_dashboard/partials/reports.html",
|
||||||
|
"wizards": "modules/manager_dashboard/templates/manager_dashboard/partials/wizards.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"):
|
||||||
|
payload = build_manager_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": "Manager Dashboard",
|
||||||
|
"active_tab": active_tab,
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(request: Request, tab: str = "overview"):
|
||||||
|
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_manager_dashboard(db, current_user):
|
||||||
|
return ui_access_denied()
|
||||||
|
active_tab = tab if tab in VALID_TABS else "overview"
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/manager_dashboard/templates/manager_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_manager_dashboard(db, current_user):
|
||||||
|
return ui_access_denied()
|
||||||
|
active_tab = tab_name if tab_name in VALID_TABS else "overview"
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
VALID_TABS[active_tab],
|
||||||
|
_ctx(request, db, current_user, active_tab=active_tab),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -4,6 +4,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.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.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
|
||||||
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_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
|
from app.modules.managers.ui import router as managers_ui_router
|
||||||
from app.modules.partner_dashboard.ui import router as partner_dashboard_router
|
from app.modules.partner_dashboard.ui import router as partner_dashboard_router
|
||||||
from app.modules.partners.ui import router as partners_ui_router
|
from app.modules.partners.ui import router as partners_ui_router
|
||||||
@@ -54,6 +55,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(work_detail_ui_router)
|
app.include_router(work_detail_ui_router)
|
||||||
app.include_router(clients_ui_router)
|
app.include_router(clients_ui_router)
|
||||||
app.include_router(employees_ui_router)
|
app.include_router(employees_ui_router)
|
||||||
|
app.include_router(manager_dashboard_router)
|
||||||
app.include_router(managers_ui_router)
|
app.include_router(managers_ui_router)
|
||||||
app.include_router(partner_dashboard_router)
|
app.include_router(partner_dashboard_router)
|
||||||
app.include_router(partners_ui_router)
|
app.include_router(partners_ui_router)
|
||||||
@@ -63,3 +65,4 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(client_portal_router)
|
app.include_router(client_portal_router)
|
||||||
app.include_router(consultant_portal_router)
|
app.include_router(consultant_portal_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user