diff --git a/app/modules/partner_dashboard/__init__.py b/app/modules/partner_dashboard/__init__.py new file mode 100644 index 0000000..a695670 --- /dev/null +++ b/app/modules/partner_dashboard/__init__.py @@ -0,0 +1 @@ +"""Partner dashboard V2 module.""" diff --git a/app/modules/partner_dashboard/service.py b/app/modules/partner_dashboard/service.py new file mode 100644 index 0000000..722b4e5 --- /dev/null +++ b/app/modules/partner_dashboard/service.py @@ -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"}, + ] diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/dashboard.html b/app/modules/partner_dashboard/templates/partner_dashboard/dashboard.html new file mode 100644 index 0000000..542ea3a --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/dashboard.html @@ -0,0 +1,72 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

Branch Operations Control Centre

+

Partner Operations Dashboard

+

+ Monitor branch clients, due work, overdue tasks, review queue, team workload, billing and operational reports. +

+

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

+
+ +
+
+ + {% set tabs = [ + ('overview','Overview'), + ('branch-work','Branch Work'), + ('clients','Clients'), + ('staff','Staff'), + ('review','Review'), + ('billing','Billing'), + ('reports','Reports'), + ('wizards','Wizards') + ] %} +
+
+ {% for code, label in tabs %} + + {% endfor %} +
+
+ +
+ {% include 'modules/partner_dashboard/templates/partner_dashboard/partials/' ~ (active_tab|replace('-', '_')) ~ '.html' ignore missing %} +
+
+ + +{% endblock %} diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/billing.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/billing.html new file mode 100644 index 0000000..49e2c6f --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/billing.html @@ -0,0 +1,5 @@ +
+
Invoices Listed
{{ billing.invoice_count }}
Draft Invoices
{{ billing.draft_count }}
Outstanding
₹ {{ billing.outstanding }}
+

Billing Control

Recent invoices in the active branch/FY scope.

Open Billing
+
{% for inv in billing.invoices %}{% else %}{% endfor %}
InvoiceClientDateTotalBalanceStatus
{{ inv.invoice_no }}{{ inv.client_legal_name or inv.client_trade_name or inv.client_id }}{{ inv.invoice_date }}₹ {{ inv.total_amount }}₹ {{ inv.balance_amount }}{{ inv.status }}
No billing invoices found in current scope.
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/branch_work.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/branch_work.html new file mode 100644 index 0000000..30ea946 --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/branch_work.html @@ -0,0 +1,18 @@ +
+
+

Branch Work Status

Due dates, assignment and status for the current partner scope.

+ Open Work Tracker +
+
+ + + + {% for item in branch_work %} + + {% else %} + + {% endfor %} + +
ClientService / TaskDueAssignedStatusAction
{{ item.client_name }}
{{ item.client_code }}
{{ item.service_name }}
{{ item.task_name }}
{{ item.due_date or '-' }}{% if item.is_overdue %}Overdue{% endif %}{{ item.assigned_to }}{{ item.status_label }}Open
No branch tasks found.
+
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html new file mode 100644 index 0000000..73b0f57 --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/clients.html @@ -0,0 +1,11 @@ +
+

Branch Clients

Client health by service count, open tasks and overdue items.

Manage Clients
+
+ {% for c in clients %} + +

{{ c.client_name }}

{{ c.client_code }}{% if c.gstin %} · {{ c.gstin }}{% elif c.pan %} · {{ c.pan }}{% endif %}

{% if c.overdue_count > 0 %}{{ c.overdue_count }} overdue{% endif %}
+
{{ c.service_count }}
Services
{{ c.task_count }}
Tasks
{{ c.overdue_count }}
Overdue
+
+ {% else %}
No branch clients found.
{% endfor %} +
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/overview.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/overview.html new file mode 100644 index 0000000..952bced --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/overview.html @@ -0,0 +1,49 @@ +
+
+ {% 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) + ] %} +
+
{{ card[0] }}
+
{{ card[1] }}
+
+ {% endfor %} +
+ +
+
+

Needs Attention

View work
+
+ {% for item in overdue[:6] + client_pending[:4] %} + +
{{ item.task_name }}
{{ item.client_name }} · {{ item.service_name }}
{{ item.status_label }}
+
Due: {{ item.due_date or '-' }} · Assigned: {{ item.assigned_to }}
+
+ {% else %} +
No overdue or blocked branch work in current scope.
+ {% endfor %} +
+
+ +
+

Review Queue

Open review board
+
+ {% for item in review_queue[:8] %} + +
{{ item.task_name }}
+
{{ item.client_name }} · {{ item.service_name }} · {{ item.assigned_to }}
+
+ {% else %} +
No completed tasks waiting for partner review.
+ {% endfor %} +
+
+
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/reports.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/reports.html new file mode 100644 index 0000000..347f627 --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/reports.html @@ -0,0 +1,5 @@ +
+ {% for report in reports %} +
{{ report.group }}

{{ report.title }}

{{ report.desc }}

View report →
+ {% endfor %} +
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html new file mode 100644 index 0000000..89281f3 --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/review.html @@ -0,0 +1,6 @@ +
+

Partner Review Queue

Completed work awaiting partner approval, rework decision or final sign-off.

Open Review Board
+
+ {% for item in review_queue %}

{{ item.task_name }}

{{ item.client_name }} · {{ item.service_name }}

Review
Prepared by: {{ item.assigned_to }} · Due: {{ item.due_date or '-' }}
{% else %}
No completed tasks waiting for partner review.
{% endfor %} +
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html new file mode 100644 index 0000000..5ff04d3 --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html @@ -0,0 +1,6 @@ +
+

Branch Staff Workload

Workload is calculated from current branch task assignments.

+
+ {% for s in staff_rows %}{% else %}{% endfor %} +
StaffActiveOverdueClient PendingReviewLoad
{{ s.name }}
{{ s.designation or s.email }}
{{ s.active }}{{ s.overdue }}{{ s.client_pending }}{{ s.review }}{{ s.load_status }}
No branch users found.
+
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/wizards.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/wizards.html new file mode 100644 index 0000000..e3b69b6 --- /dev/null +++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/wizards.html @@ -0,0 +1,5 @@ +
+ {% for wizard in wizards %} +

{{ wizard.title }}

{{ wizard.desc }}

Open →
+ {% endfor %} +
diff --git a/app/modules/partner_dashboard/ui.py b/app/modules/partner_dashboard/ui.py new file mode 100644 index 0000000..17d7130 --- /dev/null +++ b/app/modules/partner_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.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() diff --git a/app/ui/app.py b/app/ui/app.py index e2743ea..655d0ca 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -1,10 +1,11 @@ -from fastapi import FastAPI +from fastapi import FastAPI 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.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.partner_dashboard.ui import router as partner_dashboard_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.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(employees_ui_router) app.include_router(managers_ui_router) + app.include_router(partner_dashboard_router) app.include_router(partners_ui_router) app.include_router(employee_portal_router) app.include_router(consultants_ui_router) app.include_router(engagements_ui_router) app.include_router(client_portal_router) app.include_router(consultant_portal_router) +