Add partner operations dashboard v1

This commit is contained in:
A R R R Associates
2026-07-04 19:53:01 +05:30
parent 6e7355ffbf
commit 830c62c33d
13 changed files with 606 additions and 1 deletions
@@ -0,0 +1 @@
"""Partner dashboard V2 module."""
+349
View File
@@ -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>
+75
View File
@@ -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
View File
@@ -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)