Add partner operations dashboard v1
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""Partner dashboard V2 module."""
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
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.core.tenancy.models import Branch, Tenant
|
||||||
|
from app.modules.employees.models import Employee
|
||||||
|
from app.modules.services.execution import CLOSED_TASK_STATUSES
|
||||||
|
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.modules.billing.models import BillingInvoice
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
BillingInvoice = None
|
||||||
|
|
||||||
|
PARTNER_ROLES = {"Partner", "System Admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def _count(db: Session, stmt) -> int:
|
||||||
|
return int(db.execute(stmt).scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _money(value: Any) -> Decimal:
|
||||||
|
return Decimal(str(value 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 can_access_partner_dashboard(db: Session, current_user) -> bool:
|
||||||
|
roles = set(get_user_role_names(db, current_user.id))
|
||||||
|
return bool(roles.intersection(PARTNER_ROLES))
|
||||||
|
|
||||||
|
|
||||||
|
def _active_scope(db: Session, request, current_user, roles: set[str]) -> dict[str, Any]:
|
||||||
|
tenant_id = request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
|
||||||
|
branch_id = request.session.get("active_branch_id") or getattr(current_user, "branch_id", None)
|
||||||
|
|
||||||
|
if "System Admin" not in roles:
|
||||||
|
tenant_id = getattr(current_user, "tenant_id", None)
|
||||||
|
|
||||||
|
# Partner operations are branch-centric. For normal partner users, do not allow
|
||||||
|
# accidental all-branch view unless the branch context is genuinely missing.
|
||||||
|
if "System Admin" not in roles:
|
||||||
|
branch_id = getattr(current_user, "branch_id", None) or branch_id
|
||||||
|
|
||||||
|
if branch_id in (None, "", 0, "0"):
|
||||||
|
branch_id = None
|
||||||
|
|
||||||
|
tenant = db.get(Tenant, int(tenant_id)) if tenant_id else None
|
||||||
|
branch = db.get(Branch, int(branch_id)) if branch_id else None
|
||||||
|
return {
|
||||||
|
"tenant_id": int(tenant_id) if tenant_id else None,
|
||||||
|
"branch_id": int(branch_id) if branch_id else None,
|
||||||
|
"tenant": tenant,
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _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 _task_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
|
||||||
|
if tenant_id:
|
||||||
|
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id)
|
||||||
|
stmt = stmt.where(ClientServiceTaskInstance.is_active.is_(True))
|
||||||
|
if branch_id is not None:
|
||||||
|
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||||
|
if fy:
|
||||||
|
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
|
||||||
|
if "Partner" in roles and "System Admin" not in roles and branch_id is None:
|
||||||
|
stmt = stmt.where(
|
||||||
|
ClientServiceTaskInstance.subscription.has(
|
||||||
|
or_(
|
||||||
|
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
||||||
|
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
|
||||||
|
if tenant_id:
|
||||||
|
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id)
|
||||||
|
stmt = stmt.where(ClientServiceSubscription.is_active.is_(True))
|
||||||
|
if branch_id is not None:
|
||||||
|
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
|
||||||
|
if fy:
|
||||||
|
stmt = stmt.where(ClientServiceSubscription.financial_year == fy)
|
||||||
|
if "Partner" in roles and "System Admin" not in roles and branch_id is None:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
||||||
|
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _client_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]):
|
||||||
|
if tenant_id:
|
||||||
|
stmt = stmt.where(Client.tenant_id == tenant_id)
|
||||||
|
stmt = stmt.where(Client.is_active.is_(True))
|
||||||
|
if branch_id is not None:
|
||||||
|
stmt = stmt.where(Client.branch_id == branch_id)
|
||||||
|
elif "Partner" in roles and "System Admin" not in roles:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Client.partner_id == current_user.id,
|
||||||
|
Client.default_review_partner_user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _is_open_status(status: str | None) -> bool:
|
||||||
|
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def _task_label(task: ClientServiceTaskInstance) -> dict[str, Any]:
|
||||||
|
client = getattr(task, "client", None)
|
||||||
|
catalogue = getattr(task, "catalogue", None)
|
||||||
|
assignee = getattr(task, "assigned_to", None)
|
||||||
|
status = (task.status or "pending").replace("_", " ").title()
|
||||||
|
return {
|
||||||
|
"id": task.id,
|
||||||
|
"subscription_id": task.subscription_id,
|
||||||
|
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
||||||
|
"client_code": getattr(client, "client_code", None) or "",
|
||||||
|
"service_name": getattr(catalogue, "service_name", None) or "Service",
|
||||||
|
"task_name": task.task_name,
|
||||||
|
"period": task.financial_year or "-",
|
||||||
|
"due_date": task.internal_target_date,
|
||||||
|
"status": task.status or "pending",
|
||||||
|
"status_label": status,
|
||||||
|
"priority": task.priority or "normal",
|
||||||
|
"assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned",
|
||||||
|
"is_overdue": bool(task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status)),
|
||||||
|
"href": f"/work/engagements/{task.subscription_id}" if task.subscription_id else "/partner/reviews",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tasks(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None) -> list[ClientServiceTaskInstance]:
|
||||||
|
stmt = (
|
||||||
|
select(ClientServiceTaskInstance)
|
||||||
|
.options(
|
||||||
|
selectinload(ClientServiceTaskInstance.client),
|
||||||
|
selectinload(ClientServiceTaskInstance.catalogue),
|
||||||
|
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||||
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = _task_scope(stmt, tenant_id, branch_id, current_user, roles, fy)
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||||
|
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||||
|
ClientServiceTaskInstance.priority.desc(),
|
||||||
|
ClientServiceTaskInstance.id.desc(),
|
||||||
|
).limit(300)
|
||||||
|
return list(db.execute(stmt).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _load_clients(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]) -> list[dict[str, Any]]:
|
||||||
|
stmt = _client_scope(select(Client), tenant_id, branch_id, current_user, roles)
|
||||||
|
clients = db.execute(stmt.order_by(Client.client_name.asc()).limit(100)).scalars().all()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for client in clients:
|
||||||
|
task_count = _count(db, _task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceTaskInstance.client_id == client.id))
|
||||||
|
overdue_count = _count(
|
||||||
|
db,
|
||||||
|
_task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles)
|
||||||
|
.where(ClientServiceTaskInstance.client_id == client.id, ClientServiceTaskInstance.internal_target_date < date.today(), ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))),
|
||||||
|
)
|
||||||
|
service_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceSubscription.client_id == client.id))
|
||||||
|
out.append({
|
||||||
|
"id": client.id,
|
||||||
|
"client_name": client.client_name,
|
||||||
|
"client_code": client.client_code,
|
||||||
|
"pan": client.pan,
|
||||||
|
"gstin": client.gstin,
|
||||||
|
"email": client.email,
|
||||||
|
"mobile": client.mobile,
|
||||||
|
"service_count": service_count,
|
||||||
|
"task_count": task_count,
|
||||||
|
"overdue_count": overdue_count,
|
||||||
|
"href": f"/clients/{client.id}/edit",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks: list[ClientServiceTaskInstance]) -> list[dict[str, Any]]:
|
||||||
|
if not tenant_id:
|
||||||
|
return []
|
||||||
|
stmt = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True))
|
||||||
|
if branch_id is not None:
|
||||||
|
stmt = stmt.where(User.branch_id == branch_id)
|
||||||
|
users = db.execute(stmt.order_by(User.full_name.asc(), User.email.asc()).limit(100)).scalars().all()
|
||||||
|
by_user: dict[int, dict[str, int]] = {}
|
||||||
|
for task in tasks:
|
||||||
|
uid = getattr(task, "assigned_to_user_id", None)
|
||||||
|
if not uid:
|
||||||
|
continue
|
||||||
|
bucket = by_user.setdefault(int(uid), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
|
||||||
|
if _is_open_status(task.status):
|
||||||
|
bucket["active"] += 1
|
||||||
|
if task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status):
|
||||||
|
bucket["overdue"] += 1
|
||||||
|
if (task.status or "").lower() == "completed":
|
||||||
|
bucket["review"] += 1
|
||||||
|
if (task.status or "").lower() == "blocked":
|
||||||
|
bucket["client_pending"] += 1
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for user in users:
|
||||||
|
stats = by_user.get(int(user.id), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
|
||||||
|
total = stats["active"] + stats["review"]
|
||||||
|
rows.append({
|
||||||
|
"id": user.id,
|
||||||
|
"name": user.full_name or user.email,
|
||||||
|
"email": user.email,
|
||||||
|
"designation": user.designation,
|
||||||
|
"active": stats["active"],
|
||||||
|
"overdue": stats["overdue"],
|
||||||
|
"review": stats["review"],
|
||||||
|
"client_pending": stats["client_pending"],
|
||||||
|
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
|
||||||
|
})
|
||||||
|
rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"]), reverse=True)
|
||||||
|
return rows[:25]
|
||||||
|
|
||||||
|
|
||||||
|
def _billing_rows(db: Session, tenant_id: int | None, branch_id: int | None, fy: str | None) -> dict[str, Any]:
|
||||||
|
if BillingInvoice is None or not tenant_id:
|
||||||
|
return {"available": False, "invoices": [], "invoice_count": 0, "draft_count": 0, "outstanding": Decimal("0")}
|
||||||
|
stmt = select(BillingInvoice).where(BillingInvoice.tenant_id == tenant_id)
|
||||||
|
if branch_id is not None:
|
||||||
|
stmt = stmt.where(BillingInvoice.branch_id == branch_id)
|
||||||
|
if fy:
|
||||||
|
stmt = stmt.where(BillingInvoice.financial_year == fy)
|
||||||
|
invoices = list(db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc()).limit(25)).scalars().all())
|
||||||
|
outstanding = sum((_money(getattr(inv, "balance_amount", 0)) for inv in invoices), Decimal("0"))
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"invoices": invoices,
|
||||||
|
"invoice_count": len(invoices),
|
||||||
|
"draft_count": len([i for i in invoices if (getattr(i, "status", "") or "").upper() == "DRAFT"]),
|
||||||
|
"outstanding": outstanding,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
|
||||||
|
roles = set(get_user_role_names(db, current_user.id))
|
||||||
|
scope = _active_scope(db, request, current_user, roles)
|
||||||
|
tenant_id = scope["tenant_id"]
|
||||||
|
branch_id = scope["branch_id"]
|
||||||
|
fy = _financial_year(request)
|
||||||
|
today = date.today()
|
||||||
|
next_week = today + timedelta(days=7)
|
||||||
|
|
||||||
|
tasks = _load_tasks(db, tenant_id, branch_id, current_user, roles, fy)
|
||||||
|
task_rows = [_task_label(t) for t in tasks]
|
||||||
|
open_rows = [r for r in task_rows if r["status"] not in CLOSED_TASK_STATUSES]
|
||||||
|
overdue_rows = [r for r in open_rows if r["due_date"] and r["due_date"] < today]
|
||||||
|
due_today_rows = [r for r in open_rows if r["due_date"] == today]
|
||||||
|
due_week_rows = [r for r in open_rows if r["due_date"] and today <= r["due_date"] <= next_week]
|
||||||
|
client_pending_rows = [r for r in open_rows if r["status"] == "blocked"]
|
||||||
|
review_rows = [r for r in task_rows if r["status"] == "completed"]
|
||||||
|
|
||||||
|
clients = _load_clients(db, tenant_id, branch_id, current_user, roles)
|
||||||
|
staff = _staff_rows(db, tenant_id, branch_id, tasks)
|
||||||
|
billing = _billing_rows(db, tenant_id, branch_id, fy)
|
||||||
|
|
||||||
|
subscriptions_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles, fy)) if tenant_id else 0
|
||||||
|
|
||||||
|
overview = {
|
||||||
|
"tenant": scope["tenant"],
|
||||||
|
"branch": scope["branch"],
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"branch_id": branch_id,
|
||||||
|
"financial_year": fy,
|
||||||
|
"client_count": len(clients),
|
||||||
|
"subscription_count": subscriptions_count,
|
||||||
|
"open_task_count": len(open_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),
|
||||||
|
"invoice_count": billing["invoice_count"],
|
||||||
|
"outstanding": billing["outstanding"],
|
||||||
|
"today": today,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"roles": sorted(roles),
|
||||||
|
"overview": overview,
|
||||||
|
"branch_work": task_rows[:80],
|
||||||
|
"due_today": due_today_rows[:25],
|
||||||
|
"due_week": due_week_rows[:25],
|
||||||
|
"overdue": overdue_rows[:25],
|
||||||
|
"client_pending": client_pending_rows[:25],
|
||||||
|
"review_queue": review_rows[:50],
|
||||||
|
"clients": clients,
|
||||||
|
"staff_rows": staff,
|
||||||
|
"billing": billing,
|
||||||
|
"reports": _report_cards(),
|
||||||
|
"wizards": _wizard_cards(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report_cards() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"group": "Work Reports", "title": "Branch Work Status", "desc": "Due today, due this week, overdue and blocked work for the active branch.", "href": "/partner/dashboard?tab=branch-work"},
|
||||||
|
{"group": "Work Reports", "title": "Overdue Task Report", "desc": "Tasks past internal target date requiring partner escalation.", "href": "/partner/dashboard?tab=branch-work"},
|
||||||
|
{"group": "Review Reports", "title": "Partner Review Queue", "desc": "Completed work waiting for partner review or final sign-off.", "href": "/partner/dashboard?tab=review"},
|
||||||
|
{"group": "Client Reports", "title": "Branch Client Health", "desc": "Clients, services, open tasks and overdue items in the branch.", "href": "/partner/dashboard?tab=clients"},
|
||||||
|
{"group": "Staff Reports", "title": "Staff Workload", "desc": "Branch team workload, overdue count and client pending count.", "href": "/partner/dashboard?tab=staff"},
|
||||||
|
{"group": "Billing Reports", "title": "Billing Control", "desc": "Invoices raised, drafts and outstanding amount for the active branch.", "href": "/partner/dashboard?tab=billing"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wizard_cards() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"title": "Add / Manage Clients", "desc": "Open client master for branch client creation and edits.", "href": "/clients"},
|
||||||
|
{"title": "Client Service Subscriptions", "desc": "Assign firm services to branch clients and monitor engagements.", "href": "/services/engagements"},
|
||||||
|
{"title": "Generate / Track Tasks", "desc": "Use work tracker for generated compliance and audit tasks.", "href": "/work"},
|
||||||
|
{"title": "Partner Review Board", "desc": "Review completed work and send rework or clarifications.", "href": "/partner/reviews"},
|
||||||
|
{"title": "Documents", "desc": "Open branch and engagement documents.", "href": "/documents"},
|
||||||
|
{"title": "Billing", "desc": "Raise invoices and track collection follow-up.", "href": "/billing"},
|
||||||
|
]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-3xl border border-slate-200 bg-white p-6 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-600">Branch Operations Control Centre</p>
|
||||||
|
<h1 class="mt-2 text-2xl font-bold text-slate-900">Partner Operations Dashboard</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
Monitor branch clients, due work, overdue tasks, review queue, team workload, billing and operational reports.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
Scope: {{ overview.tenant.name if overview.tenant else 'Tenant' }}{% if overview.branch %} · {{ overview.branch.name }}{% else %} · All permitted branches{% endif %}{% if overview.financial_year %} · FY {{ overview.financial_year }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/partner/reviews" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Review Board</a>
|
||||||
|
<a href="/clients" class="rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Clients</a>
|
||||||
|
<a href="/billing" class="rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Billing</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% set tabs = [
|
||||||
|
('overview','Overview'),
|
||||||
|
('branch-work','Branch Work'),
|
||||||
|
('clients','Clients'),
|
||||||
|
('staff','Staff'),
|
||||||
|
('review','Review'),
|
||||||
|
('billing','Billing'),
|
||||||
|
('reports','Reports'),
|
||||||
|
('wizards','Wizards')
|
||||||
|
] %}
|
||||||
|
<section class="rounded-3xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||||
|
<div class="flex gap-2 overflow-x-auto" id="partner-dashboard-tabs">
|
||||||
|
{% for code, label in tabs %}
|
||||||
|
<button type="button" data-tab="{{ code }}" class="partner-dashboard-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="partner-dashboard-panel">
|
||||||
|
{% include 'modules/partner_dashboard/templates/partner_dashboard/partials/' ~ (active_tab|replace('-', '_')) ~ '.html' ignore missing %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const panel = document.getElementById('partner-dashboard-panel');
|
||||||
|
const buttons = Array.from(document.querySelectorAll('.partner-dashboard-tab'));
|
||||||
|
async function loadTab(tab) {
|
||||||
|
buttons.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('/partner/dashboard/tab/' + encodeURIComponent(tab), {headers: {'X-Requested-With':'fetch'}});
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
panel.innerHTML = await res.text();
|
||||||
|
history.replaceState(null, '', '/partner/dashboard?tab=' + encodeURIComponent(tab));
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-6 text-sm font-semibold text-red-700">Unable to load tab. Please refresh the page.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.forEach(btn => btn.addEventListener('click', () => loadTab(btn.dataset.tab)));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-3"><div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-400">Invoices Listed</div><div class="mt-2 text-3xl font-bold">{{ billing.invoice_count }}</div></div><div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-400">Draft Invoices</div><div class="mt-2 text-3xl font-bold">{{ billing.draft_count }}</div></div><div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-400">Outstanding</div><div class="mt-2 text-3xl font-bold">₹ {{ billing.outstanding }}</div></div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Billing Control</h2><p class="text-sm text-slate-500">Recent invoices in the active branch/FY scope.</p></div><a href="/billing" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Billing</a></div>
|
||||||
|
<div class="mt-5 overflow-x-auto"><table class="min-w-full text-left text-sm"><thead class="text-xs uppercase tracking-wide text-slate-400"><tr><th class="px-3 py-2">Invoice</th><th class="px-3 py-2">Client</th><th class="px-3 py-2">Date</th><th class="px-3 py-2">Total</th><th class="px-3 py-2">Balance</th><th class="px-3 py-2">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for inv in billing.invoices %}<tr><td class="px-3 py-3 font-semibold">{{ inv.invoice_no }}</td><td class="px-3 py-3">{{ inv.client_legal_name or inv.client_trade_name or inv.client_id }}</td><td class="px-3 py-3">{{ inv.invoice_date }}</td><td class="px-3 py-3">₹ {{ inv.total_amount }}</td><td class="px-3 py-3">₹ {{ inv.balance_amount }}</td><td class="px-3 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold">{{ inv.status }}</span></td></tr>{% else %}<tr><td colspan="6" class="px-3 py-8 text-center text-slate-500">No billing invoices found in current scope.</td></tr>{% endfor %}</tbody></table></div></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div><h2 class="text-lg font-semibold text-slate-900">Branch Work Status</h2><p class="text-sm text-slate-500">Due dates, assignment and status for the current partner scope.</p></div>
|
||||||
|
<a href="/work" class="rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Work Tracker</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-left text-sm">
|
||||||
|
<thead class="text-xs uppercase tracking-wide text-slate-400"><tr><th class="px-3 py-2">Client</th><th class="px-3 py-2">Service / Task</th><th class="px-3 py-2">Due</th><th class="px-3 py-2">Assigned</th><th class="px-3 py-2">Status</th><th class="px-3 py-2">Action</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for item in branch_work %}
|
||||||
|
<tr class="hover:bg-slate-50"><td class="px-3 py-3"><div class="font-semibold text-slate-900">{{ item.client_name }}</div><div class="text-xs text-slate-500">{{ item.client_code }}</div></td><td class="px-3 py-3"><div>{{ item.service_name }}</div><div class="text-xs text-slate-500">{{ item.task_name }}</div></td><td class="px-3 py-3">{{ item.due_date or '-' }}{% if item.is_overdue %}<span class="ml-2 rounded-full bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">Overdue</span>{% endif %}</td><td class="px-3 py-3">{{ item.assigned_to }}</td><td class="px-3 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ item.status_label }}</span></td><td class="px-3 py-3"><a href="{{ item.href }}" class="font-semibold text-brand-700">Open</a></td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="px-3 py-8 text-center text-slate-500">No branch tasks found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Branch Clients</h2><p class="text-sm text-slate-500">Client health by service count, open tasks and overdue items.</p></div><a href="/clients" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Manage Clients</a></div>
|
||||||
|
<div class="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for c in clients %}
|
||||||
|
<a href="{{ c.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50">
|
||||||
|
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ c.client_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ c.client_code }}{% if c.gstin %} · {{ c.gstin }}{% elif c.pan %} · {{ c.pan }}{% endif %}</p></div>{% if c.overdue_count > 0 %}<span class="rounded-full bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">{{ c.overdue_count }} overdue</span>{% endif %}</div>
|
||||||
|
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.service_count }}</div><div class="text-slate-500">Services</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.task_count }}</div><div class="text-slate-500">Tasks</div></div><div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ c.overdue_count }}</div><div class="text-slate-500">Overdue</div></div></div>
|
||||||
|
</a>
|
||||||
|
{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 xl:col-span-3">No branch clients found.</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{% for card in [
|
||||||
|
('Branch Clients', overview.client_count),
|
||||||
|
('Open Tasks', overview.open_task_count),
|
||||||
|
('Overdue', overview.overdue_count),
|
||||||
|
('Review Pending', overview.review_pending_count),
|
||||||
|
('Due Today', overview.due_today_count),
|
||||||
|
('Due This Week', overview.due_week_count),
|
||||||
|
('Client Pending', overview.client_pending_count),
|
||||||
|
('Outstanding', '₹ ' ~ overview.outstanding)
|
||||||
|
] %}
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ card[0] }}</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-slate-900">{{ card[1] }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
|
<section class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex items-center justify-between"><h2 class="font-semibold text-slate-900">Needs Attention</h2><a href="/partner/dashboard?tab=branch-work" class="text-xs font-semibold text-brand-700">View work</a></div>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{% for item in overdue[:6] + client_pending[:4] %}
|
||||||
|
<a href="{{ item.href }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||||
|
<div class="flex items-start justify-between gap-3"><div><div class="font-semibold text-slate-900">{{ item.task_name }}</div><div class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }}</div></div><span class="rounded-full bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">{{ item.status_label }}</span></div>
|
||||||
|
<div class="mt-2 text-xs text-slate-500">Due: {{ item.due_date or '-' }} · Assigned: {{ item.assigned_to }}</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">No overdue or blocked branch work in current scope.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex items-center justify-between"><h2 class="font-semibold text-slate-900">Review Queue</h2><a href="/partner/reviews" class="text-xs font-semibold text-brand-700">Open review board</a></div>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{% for item in review_queue[:8] %}
|
||||||
|
<a href="{{ item.href }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||||
|
<div class="font-semibold text-slate-900">{{ item.task_name }}</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }} · {{ item.assigned_to }}</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">No completed tasks waiting for partner review.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for report in reports %}
|
||||||
|
<a href="{{ report.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300 hover:bg-brand-50/30"><div class="text-xs font-semibold uppercase tracking-wide text-brand-600">{{ report.group }}</div><h3 class="mt-2 font-semibold text-slate-900">{{ report.title }}</h3><p class="mt-2 text-sm text-slate-500">{{ report.desc }}</p><div class="mt-4 text-sm font-semibold text-brand-700">View report →</div></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex items-center justify-between"><div><h2 class="text-lg font-semibold text-slate-900">Partner Review Queue</h2><p class="text-sm text-slate-500">Completed work awaiting partner approval, rework decision or final sign-off.</p></div><a href="/partner/reviews" class="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Review Board</a></div>
|
||||||
|
<div class="mt-5 grid gap-4 lg:grid-cols-2">
|
||||||
|
{% for item in review_queue %}<a href="{{ item.href }}" class="rounded-3xl border border-slate-200 p-5 hover:bg-slate-50"><div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ item.task_name }}</h3><p class="mt-1 text-xs text-slate-500">{{ item.client_name }} · {{ item.service_name }}</p></div><span class="rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">Review</span></div><div class="mt-3 text-xs text-slate-500">Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}</div></a>{% else %}<div class="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 lg:col-span-2">No completed tasks waiting for partner review.</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Branch Staff Workload</h2><p class="mt-1 text-sm text-slate-500">Workload is calculated from current branch task assignments.</p>
|
||||||
|
<div class="mt-5 overflow-x-auto"><table class="min-w-full text-left text-sm"><thead class="text-xs uppercase tracking-wide text-slate-400"><tr><th class="px-3 py-2">Staff</th><th class="px-3 py-2">Active</th><th class="px-3 py-2">Overdue</th><th class="px-3 py-2">Client Pending</th><th class="px-3 py-2">Review</th><th class="px-3 py-2">Load</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||||
|
{% for s in staff_rows %}<tr class="hover:bg-slate-50"><td class="px-3 py-3"><div class="font-semibold text-slate-900">{{ s.name }}</div><div class="text-xs text-slate-500">{{ s.designation or s.email }}</div></td><td class="px-3 py-3">{{ s.active }}</td><td class="px-3 py-3">{{ s.overdue }}</td><td class="px-3 py-3">{{ s.client_pending }}</td><td class="px-3 py-3">{{ s.review }}</td><td class="px-3 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ s.load_status }}</span></td></tr>{% else %}<tr><td colspan="6" class="px-3 py-8 text-center text-slate-500">No branch users found.</td></tr>{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for wizard in wizards %}
|
||||||
|
<a href="{{ wizard.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300 hover:bg-brand-50/30"><h3 class="font-semibold text-slate-900">{{ wizard.title }}</h3><p class="mt-2 text-sm text-slate-500">{{ wizard.desc }}</p><div class="mt-4 text-sm font-semibold text-brand-700">Open →</div></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -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.partner_dashboard.service import build_partner_dashboard_payload, can_access_partner_dashboard
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/partner", tags=["partner-dashboard-v1-ui"])
|
||||||
|
|
||||||
|
VALID_TABS = {
|
||||||
|
"overview": "modules/partner_dashboard/templates/partner_dashboard/partials/overview.html",
|
||||||
|
"branch-work": "modules/partner_dashboard/templates/partner_dashboard/partials/branch_work.html",
|
||||||
|
"clients": "modules/partner_dashboard/templates/partner_dashboard/partials/clients.html",
|
||||||
|
"staff": "modules/partner_dashboard/templates/partner_dashboard/partials/staff.html",
|
||||||
|
"review": "modules/partner_dashboard/templates/partner_dashboard/partials/review.html",
|
||||||
|
"billing": "modules/partner_dashboard/templates/partner_dashboard/partials/billing.html",
|
||||||
|
"reports": "modules/partner_dashboard/templates/partner_dashboard/partials/reports.html",
|
||||||
|
"wizards": "modules/partner_dashboard/templates/partner_dashboard/partials/wizards.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"):
|
||||||
|
payload = build_partner_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": "Partner Operations",
|
||||||
|
"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_partner_dashboard(db, current_user):
|
||||||
|
return ui_access_denied()
|
||||||
|
active_tab = tab if tab in VALID_TABS else "overview"
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/partner_dashboard/templates/partner_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_partner_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
-1
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
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.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.partners.ui import router as partners_ui_router
|
from app.modules.partners.ui import router as partners_ui_router
|
||||||
from app.modules.core.audit.ui import router as audit_ui_router
|
from app.modules.core.audit.ui import router as audit_ui_router
|
||||||
from app.modules.core.iam.ui import router as iam_ui_router
|
from app.modules.core.iam.ui import router as iam_ui_router
|
||||||
@@ -54,9 +55,11 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
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(managers_ui_router)
|
app.include_router(managers_ui_router)
|
||||||
|
app.include_router(partner_dashboard_router)
|
||||||
app.include_router(partners_ui_router)
|
app.include_router(partners_ui_router)
|
||||||
app.include_router(employee_portal_router)
|
app.include_router(employee_portal_router)
|
||||||
app.include_router(consultants_ui_router)
|
app.include_router(consultants_ui_router)
|
||||||
app.include_router(engagements_ui_router)
|
app.include_router(engagements_ui_router)
|
||||||
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