Add AQMM dashboard for assurance engagement quality monitoring
This commit is contained in:
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.modules.clients.models import Client
|
||||||
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
|
from app.modules.services.models import (
|
||||||
|
ClientServiceSubscription,
|
||||||
|
ClientServiceTaskInstance,
|
||||||
|
EngagementClosureChecklist,
|
||||||
|
ServiceCatalogue,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ASSURANCE_TYPE = "assurance"
|
||||||
|
APPROVED_STATUSES = {"approved", "completed", "complete"}
|
||||||
|
REVIEWED_STATUSES = {"reviewed", "approved", "completed", "complete", "not_required"}
|
||||||
|
EVIDENCE_OK_STATUSES = {"accepted", "approved", "reviewed", "uploaded", "not_required"}
|
||||||
|
CLOSURE_CLOSED_STATUSES = {"closed", "completed"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AqmmScope:
|
||||||
|
tenant_id: int | None
|
||||||
|
branch_id: int | None
|
||||||
|
own_user_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _active_tenant_id(request, user) -> int | None:
|
||||||
|
value = (
|
||||||
|
request.session.get("active_tenant_id")
|
||||||
|
or request.session.get("selected_tenant_id")
|
||||||
|
or request.session.get("tenant_id")
|
||||||
|
or getattr(user, "tenant_id", None)
|
||||||
|
)
|
||||||
|
return int(value) if value not in (None, "", 0, "0") else None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_branch_id(request, user, permissions: set[str]) -> int | None:
|
||||||
|
value = request.session.get("active_branch_id")
|
||||||
|
if value in (None, "", 0, "0"):
|
||||||
|
if "clients.cross_branch" in permissions or "services.cross_branch" in permissions:
|
||||||
|
return None
|
||||||
|
branch_id = getattr(user, "branch_id", None)
|
||||||
|
return int(branch_id) if branch_id not in (None, "", 0, "0") else None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_scope(db, request, user) -> AqmmScope:
|
||||||
|
roles = set(get_user_roles(db, user.id))
|
||||||
|
permissions = set(get_user_permissions(db, user.id))
|
||||||
|
own_user_id = None
|
||||||
|
if "clients.view.own_only" in permissions and "Firm Admin" not in roles:
|
||||||
|
own_user_id = int(user.id)
|
||||||
|
return AqmmScope(
|
||||||
|
tenant_id=_active_tenant_id(request, user),
|
||||||
|
branch_id=_active_branch_id(request, user, permissions),
|
||||||
|
own_user_id=own_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_aqmm_dashboard(db, user) -> bool:
|
||||||
|
roles = set(get_user_roles(db, user.id))
|
||||||
|
permissions = set(get_user_permissions(db, user.id))
|
||||||
|
if roles.intersection({"System Admin", "Firm Admin", "Partner", "Manager", "Branch Manager"}):
|
||||||
|
return True
|
||||||
|
return bool(permissions.intersection({"services.view", "clients.view", "audit.view"}))
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_filters(scope: AqmmScope, financial_year: str = "", q: str = ""):
|
||||||
|
filters = [ClientServiceSubscription.engagement_type == ASSURANCE_TYPE]
|
||||||
|
if scope.tenant_id:
|
||||||
|
filters.append(ClientServiceSubscription.tenant_id == scope.tenant_id)
|
||||||
|
if scope.branch_id:
|
||||||
|
filters.append(ClientServiceSubscription.branch_id == scope.branch_id)
|
||||||
|
if financial_year:
|
||||||
|
filters.append(ClientServiceSubscription.financial_year == financial_year)
|
||||||
|
if scope.own_user_id:
|
||||||
|
filters.append(
|
||||||
|
or_(
|
||||||
|
ClientServiceSubscription.assigned_partner_user_id == scope.own_user_id,
|
||||||
|
ClientServiceSubscription.assigned_manager_user_id == scope.own_user_id,
|
||||||
|
ClientServiceSubscription.assigned_staff_user_id == scope.own_user_id,
|
||||||
|
ClientServiceSubscription.review_partner_user_id == scope.own_user_id,
|
||||||
|
Client.partner_id == scope.own_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if q:
|
||||||
|
like = f"%{q.lower()}%"
|
||||||
|
filters.append(
|
||||||
|
or_(
|
||||||
|
func.lower(Client.client_name).like(like),
|
||||||
|
func.lower(Client.client_code).like(like),
|
||||||
|
func.lower(ServiceCatalogue.service_name).like(like),
|
||||||
|
func.lower(ServiceCatalogue.service_code).like(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def _count(db, stmt) -> int:
|
||||||
|
return int(db.execute(stmt).scalar_one() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_id_subquery(scope: AqmmScope, financial_year: str = "", q: str = ""):
|
||||||
|
return (
|
||||||
|
select(ClientServiceSubscription.id)
|
||||||
|
.join(Client, Client.id == ClientServiceSubscription.client_id)
|
||||||
|
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
|
||||||
|
.where(and_(*_subscription_filters(scope, financial_year, q)))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_count(db, subq, *extra_filters) -> int:
|
||||||
|
return _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||||
|
ClientServiceTaskInstance.subscription_id.in_(select(subq.c.id)),
|
||||||
|
ClientServiceTaskInstance.is_aqmm_task.is_(True),
|
||||||
|
*extra_filters,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_task_counts(db, subscription_id: int) -> dict[str, int]:
|
||||||
|
base = [ClientServiceTaskInstance.subscription_id == subscription_id, ClientServiceTaskInstance.is_aqmm_task.is_(True)]
|
||||||
|
total = _count(db, select(func.count(ClientServiceTaskInstance.id)).where(*base))
|
||||||
|
mandatory = _count(db, select(func.count(ClientServiceTaskInstance.id)).where(*base, ClientServiceTaskInstance.aqmm_mandatory.is_(True)))
|
||||||
|
evidence_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||||
|
*base,
|
||||||
|
ClientServiceTaskInstance.aqmm_evidence_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.evidence_status.notin_(list(EVIDENCE_OK_STATUSES)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
manager_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||||
|
*base,
|
||||||
|
ClientServiceTaskInstance.aqmm_manager_review_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.manager_review_status.notin_(list(REVIEWED_STATUSES)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
partner_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||||
|
*base,
|
||||||
|
ClientServiceTaskInstance.aqmm_partner_review_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.partner_review_status.notin_(list(REVIEWED_STATUSES)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
review_partner_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||||
|
*base,
|
||||||
|
ClientServiceTaskInstance.aqmm_review_partner_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.review_partner_review_status.notin_(list(REVIEWED_STATUSES)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
completed = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||||
|
*base,
|
||||||
|
or_(
|
||||||
|
ClientServiceTaskInstance.aqmm_status.in_(["completed", "complete", "approved"]),
|
||||||
|
ClientServiceTaskInstance.status.in_(["completed", "complete", "done"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"mandatory": mandatory,
|
||||||
|
"completed": completed,
|
||||||
|
"evidence_pending": evidence_pending,
|
||||||
|
"manager_pending": manager_pending,
|
||||||
|
"partner_pending": partner_pending,
|
||||||
|
"review_partner_pending": review_partner_pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_aqmm_dashboard_payload(db, request, user, *, financial_year: str = "", q: str = "") -> dict[str, Any]:
|
||||||
|
financial_year = _clean(financial_year)
|
||||||
|
q = _clean(q)
|
||||||
|
scope = build_scope(db, request, user)
|
||||||
|
subq = _subscription_id_subquery(scope, financial_year, q)
|
||||||
|
|
||||||
|
total_assurance = _count(db, select(func.count()).select_from(subq))
|
||||||
|
aqmm_approved = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceSubscription.id)).where(
|
||||||
|
ClientServiceSubscription.id.in_(select(subq.c.id)),
|
||||||
|
ClientServiceSubscription.quality_workflow_status.in_(list(APPROVED_STATUSES)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
aqmm_pending = max(total_assurance - aqmm_approved, 0)
|
||||||
|
|
||||||
|
kyc_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceSubscription.id)).where(
|
||||||
|
ClientServiceSubscription.id.in_(select(subq.c.id)),
|
||||||
|
ClientServiceSubscription.quality_kyc_status.notin_(list(APPROVED_STATUSES) + ["not_required"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
declaration_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceSubscription.id)).where(
|
||||||
|
ClientServiceSubscription.id.in_(select(subq.c.id)),
|
||||||
|
or_(
|
||||||
|
ClientServiceSubscription.quality_independence_status.notin_(list(APPROVED_STATUSES) + ["not_required"]),
|
||||||
|
ClientServiceSubscription.quality_conflict_status.notin_(list(APPROVED_STATUSES) + ["not_required"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
letter_pending = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(ClientServiceSubscription.id)).where(
|
||||||
|
ClientServiceSubscription.id.in_(select(subq.c.id)),
|
||||||
|
ClientServiceSubscription.quality_engagement_letter_status.notin_(list(APPROVED_STATUSES) + ["not_required"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
aqmm_tasks_total = _task_count(db, subq)
|
||||||
|
evidence_pending = _task_count(
|
||||||
|
db,
|
||||||
|
subq,
|
||||||
|
ClientServiceTaskInstance.aqmm_evidence_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.evidence_status.notin_(list(EVIDENCE_OK_STATUSES)),
|
||||||
|
)
|
||||||
|
manager_review_pending = _task_count(
|
||||||
|
db,
|
||||||
|
subq,
|
||||||
|
ClientServiceTaskInstance.aqmm_manager_review_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.manager_review_status.notin_(list(REVIEWED_STATUSES)),
|
||||||
|
)
|
||||||
|
partner_review_pending = _task_count(
|
||||||
|
db,
|
||||||
|
subq,
|
||||||
|
ClientServiceTaskInstance.aqmm_partner_review_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.partner_review_status.notin_(list(REVIEWED_STATUSES)),
|
||||||
|
)
|
||||||
|
review_partner_pending = _task_count(
|
||||||
|
db,
|
||||||
|
subq,
|
||||||
|
ClientServiceTaskInstance.aqmm_review_partner_required.is_(True),
|
||||||
|
ClientServiceTaskInstance.review_partner_review_status.notin_(list(REVIEWED_STATUSES)),
|
||||||
|
)
|
||||||
|
rework_pending = _task_count(
|
||||||
|
db,
|
||||||
|
subq,
|
||||||
|
ClientServiceTaskInstance.rework_status.in_(["requested", "pending", "rework_required"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
closure_closed = _count(
|
||||||
|
db,
|
||||||
|
select(func.count(EngagementClosureChecklist.id)).where(
|
||||||
|
EngagementClosureChecklist.subscription_id.in_(select(subq.c.id)),
|
||||||
|
EngagementClosureChecklist.closure_status.in_(list(CLOSURE_CLOSED_STATUSES)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
closure_pending = max(total_assurance - closure_closed, 0)
|
||||||
|
|
||||||
|
engagement_rows = db.execute(
|
||||||
|
select(ClientServiceSubscription, EngagementClosureChecklist)
|
||||||
|
.join(Client, Client.id == ClientServiceSubscription.client_id)
|
||||||
|
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
|
||||||
|
.outerjoin(EngagementClosureChecklist, EngagementClosureChecklist.subscription_id == ClientServiceSubscription.id)
|
||||||
|
.options(
|
||||||
|
joinedload(ClientServiceSubscription.client),
|
||||||
|
joinedload(ClientServiceSubscription.catalogue),
|
||||||
|
joinedload(ClientServiceSubscription.assigned_partner),
|
||||||
|
joinedload(ClientServiceSubscription.assigned_manager),
|
||||||
|
joinedload(ClientServiceSubscription.assigned_staff),
|
||||||
|
joinedload(ClientServiceSubscription.review_partner),
|
||||||
|
)
|
||||||
|
.where(and_(*_subscription_filters(scope, financial_year, q)))
|
||||||
|
.order_by(ClientServiceSubscription.updated_at_utc.desc(), ClientServiceSubscription.id.desc())
|
||||||
|
.limit(50)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
engagements = []
|
||||||
|
for subscription, closure in engagement_rows:
|
||||||
|
counts = _subscription_task_counts(db, subscription.id)
|
||||||
|
blockers = []
|
||||||
|
if subscription.quality_workflow_status not in APPROVED_STATUSES:
|
||||||
|
blockers.append("AQMM acceptance pending")
|
||||||
|
if counts["evidence_pending"]:
|
||||||
|
blockers.append(f"Evidence pending: {counts['evidence_pending']}")
|
||||||
|
if counts["manager_pending"]:
|
||||||
|
blockers.append(f"Manager review pending: {counts['manager_pending']}")
|
||||||
|
if counts["partner_pending"]:
|
||||||
|
blockers.append(f"Partner review pending: {counts['partner_pending']}")
|
||||||
|
if counts["review_partner_pending"]:
|
||||||
|
blockers.append(f"Review partner review pending: {counts['review_partner_pending']}")
|
||||||
|
if closure and closure.closure_status not in CLOSURE_CLOSED_STATUSES:
|
||||||
|
blockers.append("Closure pending")
|
||||||
|
engagements.append({
|
||||||
|
"row": subscription,
|
||||||
|
"closure": closure,
|
||||||
|
"counts": counts,
|
||||||
|
"blockers": blockers,
|
||||||
|
})
|
||||||
|
|
||||||
|
pending_tasks = db.execute(
|
||||||
|
select(ClientServiceTaskInstance)
|
||||||
|
.options(joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.client), joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.catalogue))
|
||||||
|
.where(
|
||||||
|
ClientServiceTaskInstance.subscription_id.in_(select(subq.c.id)),
|
||||||
|
ClientServiceTaskInstance.is_aqmm_task.is_(True),
|
||||||
|
or_(
|
||||||
|
and_(ClientServiceTaskInstance.aqmm_evidence_required.is_(True), ClientServiceTaskInstance.evidence_status.notin_(list(EVIDENCE_OK_STATUSES))),
|
||||||
|
and_(ClientServiceTaskInstance.aqmm_manager_review_required.is_(True), ClientServiceTaskInstance.manager_review_status.notin_(list(REVIEWED_STATUSES))),
|
||||||
|
and_(ClientServiceTaskInstance.aqmm_partner_review_required.is_(True), ClientServiceTaskInstance.partner_review_status.notin_(list(REVIEWED_STATUSES))),
|
||||||
|
and_(ClientServiceTaskInstance.aqmm_review_partner_required.is_(True), ClientServiceTaskInstance.review_partner_review_status.notin_(list(REVIEWED_STATUSES))),
|
||||||
|
ClientServiceTaskInstance.rework_status.in_(["requested", "pending", "rework_required"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(ClientServiceTaskInstance.internal_target_date.asc().nulls_last(), ClientServiceTaskInstance.sequence_no.asc())
|
||||||
|
.limit(50)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"financial_year": financial_year,
|
||||||
|
"q": q,
|
||||||
|
"scope": scope,
|
||||||
|
"kpis": {
|
||||||
|
"total_assurance": total_assurance,
|
||||||
|
"aqmm_approved": aqmm_approved,
|
||||||
|
"aqmm_pending": aqmm_pending,
|
||||||
|
"declaration_pending": declaration_pending,
|
||||||
|
"kyc_pending": kyc_pending,
|
||||||
|
"engagement_letter_pending": letter_pending,
|
||||||
|
"aqmm_tasks_total": aqmm_tasks_total,
|
||||||
|
"evidence_pending": evidence_pending,
|
||||||
|
"manager_review_pending": manager_review_pending,
|
||||||
|
"partner_review_pending": partner_review_pending,
|
||||||
|
"review_partner_pending": review_partner_pending,
|
||||||
|
"rework_pending": rework_pending,
|
||||||
|
"closure_pending": closure_pending,
|
||||||
|
"closure_closed": closure_closed,
|
||||||
|
},
|
||||||
|
"engagements": engagements,
|
||||||
|
"pending_tasks": pending_tasks,
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">AQMM Dashboard</h2>
|
||||||
|
<p class="text-sm text-slate-500">Assurance engagement quality status, AQMM tasks, evidence, reviews and closure readiness.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/aqmm/dashboard/export.csv?financial_year={{ financial_year or '' }}&q={{ q or '' }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export CSV</a>
|
||||||
|
<a href="/services/engagements" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Open Engagements</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="flex flex-wrap items-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</label>
|
||||||
|
<input type="text" name="financial_year" value="{{ financial_year or '' }}" placeholder="2025-26" class="w-36 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||||
|
<input type="text" name="q" value="{{ q or '' }}" placeholder="Client or service" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Assurance Engagements</p><p class="mt-2 text-3xl font-semibold text-slate-900">{{ kpis.total_assurance }}</p><p class="mt-1 text-xs text-slate-500">Within selected scope</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM Approved</p><p class="mt-2 text-3xl font-semibold text-emerald-700">{{ kpis.aqmm_approved }}</p><p class="mt-1 text-xs text-slate-500">Pending: {{ kpis.aqmm_pending }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Evidence Pending</p><p class="mt-2 text-3xl font-semibold text-amber-700">{{ kpis.evidence_pending }}</p><p class="mt-1 text-xs text-slate-500">AQMM-tagged task evidence</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Closure Pending</p><p class="mt-2 text-3xl font-semibold text-rose-700">{{ kpis.closure_pending }}</p><p class="mt-1 text-xs text-slate-500">Closed: {{ kpis.closure_closed }}</p></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Declarations Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.declaration_pending }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">KYC Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.kyc_pending }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Engagement Letter Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.engagement_letter_pending }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Rework Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.rework_pending }}</p></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-4">
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM Tasks</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.aqmm_tasks_total }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Manager Review Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.manager_review_pending }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Partner Review Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.partner_review_pending }}</p></section>
|
||||||
|
<section class="rounded-2xl bg-white p-4 shadow-soft"><p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Review Partner Pending</p><p class="mt-2 text-2xl font-semibold text-slate-900">{{ kpis.review_partner_pending }}</p></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-100 px-5 py-4">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">Assurance Engagement Quality Status</h3>
|
||||||
|
<p class="text-sm text-slate-500">Shows only assurance engagements in the selected tenant, branch and financial year.</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client / Service</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">AQMM Acceptance</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Checklist Tasks</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Pending Reviews</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Closure</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Blockers</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for item in engagements %}
|
||||||
|
{% set row = item.row %}
|
||||||
|
{% set closure = item.closure %}
|
||||||
|
{% set counts = item.counts %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<div class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</div>
|
||||||
|
<div class="text-xs text-slate-500">{{ row.catalogue.service_name if row.catalogue else '-' }} · FY {{ row.financial_year or '-' }}</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-700">
|
||||||
|
<div>Status: <span class="font-medium">{{ (row.quality_workflow_status or 'not_required')|replace('_',' ')|title }}</span></div>
|
||||||
|
<div>KYC: {{ (row.quality_kyc_status or '-')|replace('_',' ')|title }}</div>
|
||||||
|
<div>Independence: {{ (row.quality_independence_status or '-')|replace('_',' ')|title }}</div>
|
||||||
|
<div>Conflict: {{ (row.quality_conflict_status or '-')|replace('_',' ')|title }}</div>
|
||||||
|
<div>Letter: {{ (row.quality_engagement_letter_status or '-')|replace('_',' ')|title }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-700">
|
||||||
|
<div>Total: {{ counts.total }}</div>
|
||||||
|
<div>Mandatory: {{ counts.mandatory }}</div>
|
||||||
|
<div>Completed: {{ counts.completed }}</div>
|
||||||
|
<div>Evidence pending: {{ counts.evidence_pending }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-700">
|
||||||
|
<div>Manager: {{ counts.manager_pending }}</div>
|
||||||
|
<div>Partner: {{ counts.partner_pending }}</div>
|
||||||
|
<div>Review Partner: {{ counts.review_partner_pending }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-700">
|
||||||
|
<span class="rounded-full px-2 py-1 text-xs font-medium {% if closure and closure.closure_status in ['closed','completed'] %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ (closure.closure_status if closure else 'not_started')|replace('_',' ')|title }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-700">
|
||||||
|
{% if item.blockers %}
|
||||||
|
<ul class="list-disc space-y-1 pl-4">
|
||||||
|
{% for blocker in item.blockers[:4] %}<li>{{ blocker }}</li>{% endfor %}
|
||||||
|
{% if item.blockers|length > 4 %}<li>More blockers...</li>{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-emerald-700">No major blocker</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-sm"><a href="/services/engagements/{{ row.id }}" class="font-medium text-brand-700 hover:underline">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No assurance engagements found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-100 px-5 py-4">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-900">AQMM Tasks Needing Evidence / Review</h3>
|
||||||
|
<p class="text-sm text-slate-500">Tasks shown here are already part of your existing service checklist and are tagged as AQMM tasks.</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client / Engagement</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Evidence</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Review Status</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for task in pending_tasks %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ task.task_name }}</div><div class="text-xs text-slate-500">Seq {{ task.sequence_no }}{% if task.aqmm_reference %} · {{ task.aqmm_reference }}{% endif %}</div></td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600"><div>{{ task.subscription.client.client_name if task.subscription and task.subscription.client else '-' }}</div><div>{{ task.subscription.catalogue.service_name if task.subscription and task.subscription.catalogue else '-' }}</div></td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600"><div>Required: {{ 'Yes' if task.aqmm_evidence_required else 'No' }}</div><div>Status: {{ (task.evidence_status or '-')|replace('_',' ')|title }}</div></td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600"><div>Manager: {{ (task.manager_review_status or '-')|replace('_',' ')|title }}</div><div>Partner: {{ (task.partner_review_status or '-')|replace('_',' ')|title }}</div><div>Review Partner: {{ (task.review_partner_review_status or '-')|replace('_',' ')|title }}</div>{% if task.rework_status not in ['none','not_required',''] %}<div class="font-medium text-rose-700">Rework: {{ task.rework_status|replace('_',' ')|title }}</div>{% endif %}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-sm"><a href="/documents/tasks/{{ task.id }}" class="font-medium text-brand-700 hover:underline">Evidence</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No pending AQMM task evidence or reviews.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||||
|
|
||||||
|
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.aqmm_dashboard.service import build_aqmm_dashboard_payload, can_view_aqmm_dashboard
|
||||||
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/aqmm", tags=["aqmm-dashboard-ui"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(request: Request, db, user, **extra):
|
||||||
|
base = {
|
||||||
|
"request": request,
|
||||||
|
"current_user": user,
|
||||||
|
"current_user_roles": get_user_roles(db, user.id),
|
||||||
|
"current_user_permissions": get_user_permissions(db, user.id),
|
||||||
|
"csrf_token": get_or_create_csrf_token(request),
|
||||||
|
"title": "AQMM Dashboard",
|
||||||
|
}
|
||||||
|
base.update(extra)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(request: Request, financial_year: str = "", q: str = ""):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not can_view_aqmm_dashboard(db, user):
|
||||||
|
return ui_access_denied()
|
||||||
|
payload = build_aqmm_dashboard_payload(db, request, user, financial_year=financial_year, q=q)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/aqmm_dashboard/templates/aqmm_dashboard/dashboard.html",
|
||||||
|
_ctx(request, db, user, **payload),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/export.csv")
|
||||||
|
def dashboard_export_csv(request: Request, financial_year: str = "", q: str = ""):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not can_view_aqmm_dashboard(db, user):
|
||||||
|
return ui_access_denied()
|
||||||
|
payload = build_aqmm_dashboard_payload(db, request, user, financial_year=financial_year, q=q)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow([
|
||||||
|
"Client",
|
||||||
|
"Service",
|
||||||
|
"FY",
|
||||||
|
"Status",
|
||||||
|
"AQMM Status",
|
||||||
|
"KYC",
|
||||||
|
"Independence",
|
||||||
|
"Conflict",
|
||||||
|
"Engagement Letter",
|
||||||
|
"AQMM Tasks",
|
||||||
|
"Evidence Pending",
|
||||||
|
"Manager Review Pending",
|
||||||
|
"Partner Review Pending",
|
||||||
|
"Review Partner Pending",
|
||||||
|
"Closure Status",
|
||||||
|
"Blockers",
|
||||||
|
])
|
||||||
|
for item in payload["engagements"]:
|
||||||
|
row = item["row"]
|
||||||
|
closure = item.get("closure")
|
||||||
|
counts = item["counts"]
|
||||||
|
writer.writerow([
|
||||||
|
row.client.client_name if row.client else "",
|
||||||
|
row.catalogue.service_name if row.catalogue else "",
|
||||||
|
row.financial_year or "",
|
||||||
|
row.status or "",
|
||||||
|
row.quality_workflow_status or "",
|
||||||
|
row.quality_kyc_status or "",
|
||||||
|
row.quality_independence_status or "",
|
||||||
|
row.quality_conflict_status or "",
|
||||||
|
row.quality_engagement_letter_status or "",
|
||||||
|
counts.get("total", 0),
|
||||||
|
counts.get("evidence_pending", 0),
|
||||||
|
counts.get("manager_pending", 0),
|
||||||
|
counts.get("partner_pending", 0),
|
||||||
|
counts.get("review_partner_pending", 0),
|
||||||
|
closure.closure_status if closure else "not_started",
|
||||||
|
"; ".join(item.get("blockers") or []),
|
||||||
|
])
|
||||||
|
|
||||||
|
data = buf.getvalue().encode("utf-8-sig")
|
||||||
|
filename = f"aqmm_dashboard_{financial_year or 'all'}.csv"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
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
|
||||||
@@ -29,6 +29,7 @@ from app.modules.system_admin_dashboard.ui import router as system_admin_dashboa
|
|||||||
from app.ui.routes.auth import router as auth_router
|
from app.ui.routes.auth import router as auth_router
|
||||||
from app.modules.workspace_navigation.ui import router as workspace_navigation_router
|
from app.modules.workspace_navigation.ui import router as workspace_navigation_router
|
||||||
from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
||||||
|
from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
|
||||||
|
|
||||||
|
|
||||||
def mount_ui(app: FastAPI) -> None:
|
def mount_ui(app: FastAPI) -> None:
|
||||||
@@ -44,6 +45,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(rbac_ui_router)
|
app.include_router(rbac_ui_router)
|
||||||
app.include_router(audit_ui_router)
|
app.include_router(audit_ui_router)
|
||||||
app.include_router(services_ui_router)
|
app.include_router(services_ui_router)
|
||||||
|
app.include_router(aqmm_dashboard_router)
|
||||||
app.include_router(work_tracker_ui_router)
|
app.include_router(work_tracker_ui_router)
|
||||||
app.include_router(billing_ui_router)
|
app.include_router(billing_ui_router)
|
||||||
app.include_router(platform_billing_ui_router)
|
app.include_router(platform_billing_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user