Add System Admin tab dashboard v1
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""System Admin utility dashboard package."""
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.core.iam.models import User
|
||||||
|
from app.modules.core.iam.password_flows_models import InviteToken
|
||||||
|
from app.modules.core.rbac.models import Role, UserRole
|
||||||
|
from app.modules.core.tenancy.models import Branch, Tenant, YearBackupExport
|
||||||
|
from app.modules.services.models import (
|
||||||
|
ServiceCatalogue,
|
||||||
|
ServiceCategory,
|
||||||
|
ServiceDefaultTaskTemplate,
|
||||||
|
ServiceDueDateRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.modules.core.audit.models import AuditLog
|
||||||
|
except Exception: # pragma: no cover - keeps dashboard usable if audit module is unavailable
|
||||||
|
AuditLog = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.modules.email_integration.models import PlatformEmailSetting
|
||||||
|
except Exception: # pragma: no cover - dashboard falls back to not configured
|
||||||
|
PlatformEmailSetting = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
STORAGE_ROOT = Path("/app/data/storage")
|
||||||
|
BRANDING_ROOT = STORAGE_ROOT / "uploads" / "branding"
|
||||||
|
|
||||||
|
|
||||||
|
def scalar_count(db: Session, statement) -> int:
|
||||||
|
value = db.execute(statement).scalar()
|
||||||
|
return int(value or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def system_admin_role_names(db: Session, user_id: int) -> set[str]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(Role.name)
|
||||||
|
.join(UserRole, UserRole.role_id == Role.id)
|
||||||
|
.where(UserRole.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
return {name for (name,) in rows if name}
|
||||||
|
|
||||||
|
|
||||||
|
def is_system_admin(db: Session, user: User | None) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
return "System Admin" in system_admin_role_names(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _firm_admin_role_ids(db: Session) -> list[int]:
|
||||||
|
names = ["Firm Admin", "firm_admin", "FirmAdmin"]
|
||||||
|
return [
|
||||||
|
role_id
|
||||||
|
for (role_id,) in db.execute(select(Role.id).where(Role.name.in_(names))).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_firm_admin_invite_count(db: Session) -> int:
|
||||||
|
role_ids = _firm_admin_role_ids(db)
|
||||||
|
if not role_ids:
|
||||||
|
return 0
|
||||||
|
return scalar_count(
|
||||||
|
db,
|
||||||
|
select(func.count(func.distinct(User.id)))
|
||||||
|
.join(UserRole, UserRole.user_id == User.id)
|
||||||
|
.join(InviteToken, InviteToken.user_id == User.id)
|
||||||
|
.where(UserRole.role_id.in_(role_ids))
|
||||||
|
.where(InviteToken.used_at_utc.is_(None))
|
||||||
|
.where(User.must_change_password.is_(True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_smtp_status(db: Session) -> dict[str, Any]:
|
||||||
|
if PlatformEmailSetting is None:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"active": False,
|
||||||
|
"host": None,
|
||||||
|
"port": None,
|
||||||
|
"from_email": None,
|
||||||
|
"from_name": None,
|
||||||
|
"security": None,
|
||||||
|
"message": "Platform SMTP model not available. Apply W2 SMTP migration/package first.",
|
||||||
|
}
|
||||||
|
setting = db.execute(select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc())).scalars().first()
|
||||||
|
if not setting:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"active": False,
|
||||||
|
"host": None,
|
||||||
|
"port": None,
|
||||||
|
"from_email": None,
|
||||||
|
"from_name": None,
|
||||||
|
"security": None,
|
||||||
|
"message": "Platform SMTP is not configured.",
|
||||||
|
}
|
||||||
|
configured = bool(setting.smtp_host and setting.smtp_port and setting.from_email)
|
||||||
|
return {
|
||||||
|
"configured": configured,
|
||||||
|
"active": bool(getattr(setting, "is_active", False)),
|
||||||
|
"host": setting.smtp_host,
|
||||||
|
"port": setting.smtp_port,
|
||||||
|
"from_email": setting.from_email,
|
||||||
|
"from_name": setting.from_name,
|
||||||
|
"security": setting.smtp_security,
|
||||||
|
"message": "Platform SMTP is ready." if configured and setting.is_active else "Platform SMTP is saved but not fully ready.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_size_bytes(path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
for item in path.rglob("*"):
|
||||||
|
try:
|
||||||
|
if item.is_file():
|
||||||
|
total += item.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _file_count(path: Path) -> int:
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
for item in path.rglob("*"):
|
||||||
|
try:
|
||||||
|
if item.is_file():
|
||||||
|
count += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(size: int) -> str:
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
value = float(size)
|
||||||
|
for unit in units:
|
||||||
|
if value < 1024 or unit == units[-1]:
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(value)} {unit}"
|
||||||
|
return f"{value:.1f} {unit}"
|
||||||
|
value /= 1024
|
||||||
|
return f"{size} B"
|
||||||
|
|
||||||
|
|
||||||
|
def get_storage_status(db: Session) -> dict[str, Any]:
|
||||||
|
root_exists = STORAGE_ROOT.exists()
|
||||||
|
branding_exists = BRANDING_ROOT.exists()
|
||||||
|
root_size = _dir_size_bytes(STORAGE_ROOT)
|
||||||
|
branding_files = _file_count(BRANDING_ROOT)
|
||||||
|
backup_exports = scalar_count(db, select(func.count(YearBackupExport.id)))
|
||||||
|
return {
|
||||||
|
"storage_root": str(STORAGE_ROOT),
|
||||||
|
"storage_root_exists": root_exists,
|
||||||
|
"branding_root": str(BRANDING_ROOT),
|
||||||
|
"branding_root_exists": branding_exists,
|
||||||
|
"branding_file_count": branding_files,
|
||||||
|
"backup_export_count": backup_exports,
|
||||||
|
"used_bytes": root_size,
|
||||||
|
"used_display": format_bytes(root_size),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_overview(db: Session) -> dict[str, Any]:
|
||||||
|
total_firms = scalar_count(db, select(func.count(Tenant.id)))
|
||||||
|
active_firms = scalar_count(db, select(func.count(Tenant.id)).where(Tenant.is_active.is_(True)))
|
||||||
|
inactive_firms = max(total_firms - active_firms, 0)
|
||||||
|
service_count = scalar_count(db, select(func.count(ServiceCatalogue.id)))
|
||||||
|
active_service_count = scalar_count(db, select(func.count(ServiceCatalogue.id)).where(ServiceCatalogue.is_active.is_(True)))
|
||||||
|
category_count = scalar_count(db, select(func.count(ServiceCategory.id)))
|
||||||
|
default_task_count = scalar_count(db, select(func.count(ServiceDefaultTaskTemplate.id)))
|
||||||
|
due_rule_count = scalar_count(db, select(func.count(ServiceDueDateRule.id)))
|
||||||
|
pending_invites = _pending_firm_admin_invite_count(db)
|
||||||
|
storage = get_storage_status(db)
|
||||||
|
smtp = _platform_smtp_status(db)
|
||||||
|
return {
|
||||||
|
"total_firms": total_firms,
|
||||||
|
"active_firms": active_firms,
|
||||||
|
"inactive_firms": inactive_firms,
|
||||||
|
"pending_invites": pending_invites,
|
||||||
|
"service_count": service_count,
|
||||||
|
"active_service_count": active_service_count,
|
||||||
|
"category_count": category_count,
|
||||||
|
"default_task_count": default_task_count,
|
||||||
|
"due_rule_count": due_rule_count,
|
||||||
|
"storage": storage,
|
||||||
|
"smtp": smtp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_firms(db: Session, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
branch_counts = dict(
|
||||||
|
db.execute(
|
||||||
|
select(Branch.tenant_id, func.count(Branch.id)).group_by(Branch.tenant_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
user_counts = dict(
|
||||||
|
db.execute(
|
||||||
|
select(User.tenant_id, func.count(User.id)).group_by(User.tenant_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
role_ids = _firm_admin_role_ids(db)
|
||||||
|
primary_admins: dict[int, str] = {}
|
||||||
|
if role_ids:
|
||||||
|
rows = db.execute(
|
||||||
|
select(User.tenant_id, User.email, User.full_name)
|
||||||
|
.join(UserRole, UserRole.user_id == User.id)
|
||||||
|
.where(UserRole.role_id.in_(role_ids))
|
||||||
|
.order_by(User.id.asc())
|
||||||
|
).all()
|
||||||
|
for tenant_id, email, full_name in rows:
|
||||||
|
if tenant_id not in primary_admins:
|
||||||
|
primary_admins[tenant_id] = full_name or email
|
||||||
|
|
||||||
|
tenants = db.execute(select(Tenant).order_by(Tenant.id.desc()).limit(limit)).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": tenant.id,
|
||||||
|
"code": tenant.code,
|
||||||
|
"name": tenant.display_name or tenant.name,
|
||||||
|
"legal_name": tenant.name,
|
||||||
|
"firm_type": tenant.firm_type,
|
||||||
|
"is_active": tenant.is_active,
|
||||||
|
"branch_count": int(branch_counts.get(tenant.id, 0) or 0),
|
||||||
|
"user_count": int(user_counts.get(tenant.id, 0) or 0),
|
||||||
|
"primary_admin": primary_admins.get(tenant.id, "-"),
|
||||||
|
"contact_email": tenant.contact_email,
|
||||||
|
}
|
||||||
|
for tenant in tenants
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_catalogue_summary(db: Session) -> dict[str, Any]:
|
||||||
|
categories = db.execute(select(ServiceCategory).order_by(ServiceCategory.sort_order.asc(), ServiceCategory.name.asc())).scalars().all()
|
||||||
|
services = db.execute(select(ServiceCatalogue).order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()).limit(100)).scalars().all()
|
||||||
|
task_counts = dict(
|
||||||
|
db.execute(
|
||||||
|
select(ServiceDefaultTaskTemplate.service_catalogue_id, func.count(ServiceDefaultTaskTemplate.id))
|
||||||
|
.group_by(ServiceDefaultTaskTemplate.service_catalogue_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
rule_counts = dict(
|
||||||
|
db.execute(
|
||||||
|
select(ServiceDueDateRule.service_catalogue_id, func.count(ServiceDueDateRule.id))
|
||||||
|
.group_by(ServiceDueDateRule.service_catalogue_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"category_count": scalar_count(db, select(func.count(ServiceCategory.id))),
|
||||||
|
"service_count": scalar_count(db, select(func.count(ServiceCatalogue.id))),
|
||||||
|
"active_service_count": scalar_count(db, select(func.count(ServiceCatalogue.id)).where(ServiceCatalogue.is_active.is_(True))),
|
||||||
|
"default_task_count": scalar_count(db, select(func.count(ServiceDefaultTaskTemplate.id))),
|
||||||
|
"due_rule_count": scalar_count(db, select(func.count(ServiceDueDateRule.id))),
|
||||||
|
"categories": categories,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": service.id,
|
||||||
|
"code": service.service_code,
|
||||||
|
"name": service.service_name,
|
||||||
|
"category": service.category or (service.service_category.name if service.service_category else "-"),
|
||||||
|
"recurrence_type": service.recurrence_type or "-",
|
||||||
|
"engagement_type": service.engagement_type,
|
||||||
|
"is_active": service.is_active,
|
||||||
|
"task_count": int(task_counts.get(service.id, 0) or 0),
|
||||||
|
"rule_count": int(rule_counts.get(service.id, 0) or 0),
|
||||||
|
}
|
||||||
|
for service in services
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_smtp_summary(db: Session) -> dict[str, Any]:
|
||||||
|
return _platform_smtp_status(db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_report_cards(db: Session) -> list[dict[str, str]]:
|
||||||
|
overview = get_overview(db)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": "Firm Setup Completeness",
|
||||||
|
"description": "Review firms, admin invite status, branches and basic readiness.",
|
||||||
|
"metric": f"{overview['total_firms']} firms",
|
||||||
|
"href": "/system-admin/dashboard?tab=firms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Firm Admin Invite Status",
|
||||||
|
"description": "Track Firm Admin users whose invite/password setup is still pending.",
|
||||||
|
"metric": f"{overview['pending_invites']} pending",
|
||||||
|
"href": "/system-admin/dashboard?tab=firms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Service Catalogue Readiness",
|
||||||
|
"description": "Check categories, services, default task templates and due-date rules.",
|
||||||
|
"metric": f"{overview['service_count']} services / {overview['default_task_count']} tasks",
|
||||||
|
"href": "/system-admin/dashboard?tab=catalogue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Platform SMTP Status",
|
||||||
|
"description": "Check whether system emails can be sent for firm invites and security notices.",
|
||||||
|
"metric": "Ready" if overview["smtp"]["configured"] and overview["smtp"]["active"] else "Needs setup",
|
||||||
|
"href": "/system-admin/dashboard?tab=smtp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Storage Status",
|
||||||
|
"description": "Verify persistent storage, branding files and FY backup exports.",
|
||||||
|
"metric": overview["storage"]["used_display"],
|
||||||
|
"href": "/system-admin/dashboard?tab=storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Audit Log Review",
|
||||||
|
"description": "Review recent platform and system-level actions.",
|
||||||
|
"metric": "Latest 25",
|
||||||
|
"href": "/system-admin/dashboard?tab=audit-logs",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_wizard_cards() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": "Create Firm Wizard",
|
||||||
|
"description": "Create a firm, primary branch, Firm Admin, employee link and invite.",
|
||||||
|
"href": "/wizards/system/firm/new",
|
||||||
|
"action": "Open Wizard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Platform SMTP Settings",
|
||||||
|
"description": "Configure SMTP used for firm creation invites and system emails.",
|
||||||
|
"href": "/email/platform-smtp",
|
||||||
|
"action": "Configure SMTP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Service Catalogue",
|
||||||
|
"description": "Manage master service catalogue and default task templates.",
|
||||||
|
"href": "/services/catalogue",
|
||||||
|
"action": "Open Catalogue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Bulk Service Imports",
|
||||||
|
"description": "Use existing bulk import screens for service master and default tasks.",
|
||||||
|
"href": "/services/bulk-imports",
|
||||||
|
"action": "Open Imports",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_audit_logs(db: Session, limit: int = 25) -> list[Any]:
|
||||||
|
if AuditLog is None:
|
||||||
|
return []
|
||||||
|
return db.execute(select(AuditLog).order_by(AuditLog.created_at_utc.desc()).limit(limit)).scalars().all()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="space-y-6">
|
||||||
|
<div 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">Platform Utility Centre</p>
|
||||||
|
<h1 class="mt-2 text-2xl font-bold text-slate-900">System Admin Dashboard</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Monitor firms, service catalogue, SMTP, storage, reports, wizards and audit logs from one place.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/wizards/system/firm/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">+ Create Firm</a>
|
||||||
|
<a href="/email/platform-smtp" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Platform SMTP</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set tabs = [
|
||||||
|
('overview', 'Overview'),
|
||||||
|
('firms', 'Firms'),
|
||||||
|
('catalogue', 'Service Catalogue'),
|
||||||
|
('smtp', 'Platform SMTP'),
|
||||||
|
('storage', 'Storage'),
|
||||||
|
('reports', 'Reports'),
|
||||||
|
('wizards', 'Wizards'),
|
||||||
|
('audit-logs', 'Audit Logs')
|
||||||
|
] %}
|
||||||
|
<div class="mt-6 overflow-x-auto">
|
||||||
|
<div class="flex min-w-max gap-2 rounded-2xl bg-slate-100 p-1">
|
||||||
|
{% for tab_key, tab_label in tabs %}
|
||||||
|
<a href="/system-admin/dashboard?tab={{ tab_key }}"
|
||||||
|
data-dashboard-tab="{{ tab_key }}"
|
||||||
|
class="system-admin-tab rounded-xl px-4 py-2 text-sm font-semibold transition {% if active_tab == tab_key %}bg-white text-brand-700 shadow-soft{% else %}text-slate-600 hover:bg-white hover:text-slate-900{% endif %}">
|
||||||
|
{{ tab_label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="system-admin-dashboard-panel"
|
||||||
|
data-initial-tab="{{ active_tab }}"
|
||||||
|
class="min-h-[360px]">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">Loading dashboard...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const panel = document.getElementById('system-admin-dashboard-panel');
|
||||||
|
const tabs = Array.from(document.querySelectorAll('[data-dashboard-tab]'));
|
||||||
|
if (!panel || !tabs.length) return;
|
||||||
|
|
||||||
|
function setActive(tabName) {
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
const active = tab.dataset.dashboardTab === tabName;
|
||||||
|
tab.classList.toggle('bg-white', active);
|
||||||
|
tab.classList.toggle('text-brand-700', active);
|
||||||
|
tab.classList.toggle('shadow-soft', active);
|
||||||
|
tab.classList.toggle('text-slate-600', !active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTab(tabName, pushState) {
|
||||||
|
setActive(tabName);
|
||||||
|
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 response = await fetch('/system-admin/dashboard/tab/' + encodeURIComponent(tabName), {
|
||||||
|
headers: { 'X-Requested-With': 'fetch' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Tab load failed');
|
||||||
|
panel.innerHTML = await response.text();
|
||||||
|
if (pushState) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', tabName);
|
||||||
|
history.pushState({ tab: tabName }, '', url.toString());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-6 text-sm text-red-700 shadow-soft">Unable to load this tab. Please refresh the page.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
tab.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadTab(tab.dataset.dashboardTab, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function () {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
loadTab(url.searchParams.get('tab') || 'overview', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTab(panel.dataset.initialTab || 'overview', false);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-100 p-5"><h2 class="text-lg font-bold text-slate-900">Recent Audit Logs</h2><p class="text-sm text-slate-500">Latest 25 audit events.</p></div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-5 py-3">Time</th><th class="px-5 py-3">User</th><th class="px-5 py-3">Action</th><th class="px-5 py-3">Entity</th><th class="px-5 py-3">Status</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for log in audit_logs %}
|
||||||
|
<tr class="hover:bg-slate-50"><td class="px-5 py-3 whitespace-nowrap">{{ log.created_at_utc }}</td><td class="px-5 py-3">{{ log.actor_email or '-' }}</td><td class="px-5 py-3 font-semibold text-slate-900">{{ log.action }}</td><td class="px-5 py-3">{{ log.entity_type }}{% if log.entity_name %} > {{ log.entity_name }}{% endif %}</td><td class="px-5 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ log.status }}</span></td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No audit logs found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Categories</div><div class="mt-2 text-2xl font-bold">{{ catalogue.category_count }}</div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Services</div><div class="mt-2 text-2xl font-bold">{{ catalogue.service_count }}</div><div class="text-xs text-slate-500">{{ catalogue.active_service_count }} active</div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Default Tasks</div><div class="mt-2 text-2xl font-bold">{{ catalogue.default_task_count }}</div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Due Rules</div><div class="mt-2 text-2xl font-bold">{{ catalogue.due_rule_count }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||||
|
<div class="flex flex-col gap-3 border-b border-slate-100 p-5 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div><h2 class="text-lg font-bold text-slate-900">Service Catalogue Readiness</h2><p class="text-sm text-slate-500">First 100 catalogue services with default task/rule counts.</p></div>
|
||||||
|
<div class="flex flex-wrap gap-2"><a href="/services/catalogue" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Catalogue</a><a href="/services/default-templates" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Default Templates</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-5 py-3">Code</th><th class="px-5 py-3">Service</th><th class="px-5 py-3">Category</th><th class="px-5 py-3">Recurrence</th><th class="px-5 py-3 text-right">Tasks</th><th class="px-5 py-3 text-right">Rules</th><th class="px-5 py-3">Status</th></tr></thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for service in catalogue.services %}
|
||||||
|
<tr class="hover:bg-slate-50"><td class="px-5 py-3 font-semibold">{{ service.code }}</td><td class="px-5 py-3">{{ service.name }}</td><td class="px-5 py-3">{{ service.category }}</td><td class="px-5 py-3">{{ service.recurrence_type }}</td><td class="px-5 py-3 text-right font-semibold">{{ service.task_count }}</td><td class="px-5 py-3 text-right font-semibold">{{ service.rule_count }}</td><td class="px-5 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if service.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-600{% endif %}">{% if service.is_active %}Active{% else %}Inactive{% endif %}</span></td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-5 py-8 text-center text-slate-500">No service catalogue found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||||
|
<div class="flex flex-col gap-3 border-b border-slate-100 p-5 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div><h2 class="text-lg font-bold text-slate-900">Firms / Tenants</h2><p class="text-sm text-slate-500">Latest firms created on the platform.</p></div>
|
||||||
|
<a href="/wizards/system/firm/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Create Firm</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
<tr><th class="px-5 py-3">Code</th><th class="px-5 py-3">Firm</th><th class="px-5 py-3">Status</th><th class="px-5 py-3">Firm Admin</th><th class="px-5 py-3 text-right">Branches</th><th class="px-5 py-3 text-right">Users</th><th class="px-5 py-3">Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for firm in firms %}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-5 py-3 font-semibold text-slate-900">{{ firm.code }}</td>
|
||||||
|
<td class="px-5 py-3"><div class="font-medium text-slate-900">{{ firm.name }}</div><div class="text-xs text-slate-500">{{ firm.firm_type or '-' }}{% if firm.contact_email %} > {{ firm.contact_email }}{% endif %}</div></td>
|
||||||
|
<td class="px-5 py-3"><span class="rounded-full px-2.5 py-1 text-xs font-semibold {% if firm.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-600{% endif %}">{% if firm.is_active %}Active{% else %}Inactive{% endif %}</span></td>
|
||||||
|
<td class="px-5 py-3 text-slate-700">{{ firm.primary_admin }}</td>
|
||||||
|
<td class="px-5 py-3 text-right font-semibold">{{ firm.branch_count }}</td>
|
||||||
|
<td class="px-5 py-3 text-right font-semibold">{{ firm.user_count }}</td>
|
||||||
|
<td class="px-5 py-3"><a href="/system-settings/tenants" class="text-sm font-semibold text-brand-700 hover:text-brand-900">Open Settings</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-5 py-8 text-center text-slate-500">No firms found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<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-500">Total Firms</div><div class="mt-2 text-3xl font-bold text-slate-900">{{ overview.total_firms }}</div></div>
|
||||||
|
<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-500">Active Firms</div><div class="mt-2 text-3xl font-bold text-emerald-700">{{ overview.active_firms }}</div></div>
|
||||||
|
<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-500">Pending Invites</div><div class="mt-2 text-3xl font-bold text-amber-700">{{ overview.pending_invites }}</div></div>
|
||||||
|
<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-500">Platform SMTP</div><div class="mt-2 text-lg font-bold {% if overview.smtp.configured and overview.smtp.active %}text-emerald-700{% else %}text-amber-700{% endif %}">{% if overview.smtp.configured and overview.smtp.active %}Ready{% else %}Needs Setup{% endif %}</div><div class="mt-1 text-xs text-slate-500">{{ overview.smtp.message }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<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-500">Service Catalogue</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ overview.service_count }}</div><div class="mt-1 text-xs text-slate-500">{{ overview.active_service_count }} active services</div></div>
|
||||||
|
<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-500">Default Task Templates</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ overview.default_task_count }}</div><div class="mt-1 text-xs text-slate-500">{{ overview.due_rule_count }} due-date rules</div></div>
|
||||||
|
<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-500">Storage Used</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ overview.storage.used_display }}</div><div class="mt-1 text-xs text-slate-500">Branding files: {{ overview.storage.branding_file_count }}</div></div>
|
||||||
|
<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-500">Inactive Firms</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ overview.inactive_firms }}</div><div class="mt-1 text-xs text-slate-500">Use Firms tab for details</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<a href="/wizards/system/firm/new" class="rounded-3xl border border-brand-100 bg-white p-5 shadow-soft hover:border-brand-300 hover:bg-brand-50">
|
||||||
|
<div class="text-sm font-bold text-brand-700">Create Firm Wizard</div><p class="mt-2 text-sm text-slate-600">Create tenant, branch, Firm Admin, employee link and invite.</p>
|
||||||
|
</a>
|
||||||
|
<a href="/email/platform-smtp" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:bg-slate-50">
|
||||||
|
<div class="text-sm font-bold text-slate-900">Platform SMTP</div><p class="mt-2 text-sm text-slate-600">Configure system email for invites and platform notifications.</p>
|
||||||
|
</a>
|
||||||
|
<a href="/system-admin/dashboard?tab=reports" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:bg-slate-50">
|
||||||
|
<div class="text-sm font-bold text-slate-900">Reports Centre</div><p class="mt-2 text-sm text-slate-600">Open setup, SMTP, storage and audit report cards.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{% for card in report_cards %}
|
||||||
|
<a href="{{ card.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200 hover:bg-brand-50">
|
||||||
|
<div class="flex items-start justify-between gap-3"><h3 class="font-bold text-slate-900">{{ card.title }}</h3><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ card.metric }}</span></div>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">{{ card.description }}</p>
|
||||||
|
<div class="mt-4 text-sm font-semibold text-brand-700">View report</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft lg:col-span-2">
|
||||||
|
<h2 class="text-lg font-bold text-slate-900">Platform SMTP</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Used for Firm Creation Wizard invites and system-level emails.</p>
|
||||||
|
<dl class="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">Status</dt><dd class="mt-1 font-bold {% if smtp.configured and smtp.active %}text-emerald-700{% else %}text-amber-700{% endif %}">{% if smtp.configured and smtp.active %}Ready{% else %}Needs Setup{% endif %}</dd></div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">Host / Port</dt><dd class="mt-1 font-semibold text-slate-900">{{ smtp.host or '-' }}{% if smtp.port %}:{{ smtp.port }}{% endif %}</dd></div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">From Email</dt><dd class="mt-1 font-semibold text-slate-900">{{ smtp.from_email or '-' }}</dd></div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">Security</dt><dd class="mt-1 font-semibold text-slate-900">{{ smtp.security or '-' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="mt-4 text-sm text-slate-600">{{ smtp.message }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||||
|
<h3 class="font-bold text-slate-900">Action</h3>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Configure or test platform SMTP from the existing settings page.</p>
|
||||||
|
<a href="/email/platform-smtp" class="mt-5 inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Platform SMTP</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Storage Root</div><div class="mt-2 text-lg font-bold {% if storage.storage_root_exists %}text-emerald-700{% else %}text-red-700{% endif %}">{% if storage.storage_root_exists %}Available{% else %}Missing{% endif %}</div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Used</div><div class="mt-2 text-lg font-bold">{{ storage.used_display }}</div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Branding Files</div><div class="mt-2 text-lg font-bold">{{ storage.branding_file_count }}</div></div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">FY Backups</div><div class="mt-2 text-lg font-bold">{{ storage.backup_export_count }}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||||
|
<h2 class="text-lg font-bold text-slate-900">Persistent Storage Paths</h2>
|
||||||
|
<div class="mt-4 space-y-3 text-sm">
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4"><span class="font-semibold text-slate-700">Storage root:</span> <code class="text-slate-800">{{ storage.storage_root }}</code></div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4"><span class="font-semibold text-slate-700">Branding root:</span> <code class="text-slate-800">{{ storage.branding_root }}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{% for card in wizard_cards %}
|
||||||
|
<a href="{{ card.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200 hover:bg-brand-50">
|
||||||
|
<h3 class="font-bold text-slate-900">{{ card.title }}</h3>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">{{ card.description }}</p>
|
||||||
|
<div class="mt-4 inline-flex rounded-xl bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ card.action }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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.system_admin_dashboard.service import (
|
||||||
|
get_catalogue_summary,
|
||||||
|
get_overview,
|
||||||
|
get_recent_audit_logs,
|
||||||
|
get_report_cards,
|
||||||
|
get_smtp_summary,
|
||||||
|
get_storage_status,
|
||||||
|
get_wizard_cards,
|
||||||
|
is_system_admin,
|
||||||
|
list_firms,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system-admin", tags=["system-admin-dashboard-ui"])
|
||||||
|
|
||||||
|
_ALLOWED_TABS = {
|
||||||
|
"overview",
|
||||||
|
"firms",
|
||||||
|
"catalogue",
|
||||||
|
"smtp",
|
||||||
|
"storage",
|
||||||
|
"reports",
|
||||||
|
"wizards",
|
||||||
|
"audit-logs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _login_redirect() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_context(request: Request, db, current_user, **extra):
|
||||||
|
ctx = {
|
||||||
|
"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),
|
||||||
|
}
|
||||||
|
ctx.update(extra)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _system_admin_or_response(request: Request, db):
|
||||||
|
current_user = get_current_user(request, db=db)
|
||||||
|
if not current_user:
|
||||||
|
return None, _login_redirect()
|
||||||
|
if not is_system_admin(db, current_user):
|
||||||
|
return None, ui_access_denied()
|
||||||
|
return current_user, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(request: Request, tab: str = "overview"):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
current_user, response = _system_admin_or_response(request, db)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
active_tab = tab if tab in _ALLOWED_TABS else "overview"
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/system_admin_dashboard/templates/system_admin_dashboard/dashboard.html",
|
||||||
|
_base_context(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
title="System Admin Dashboard",
|
||||||
|
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, response = _system_admin_or_response(request, db)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
if tab_name not in _ALLOWED_TABS:
|
||||||
|
tab_name = "overview"
|
||||||
|
|
||||||
|
extra = {"active_tab": tab_name}
|
||||||
|
template = f"modules/system_admin_dashboard/templates/system_admin_dashboard/partials/{tab_name.replace('-', '_')}.html"
|
||||||
|
|
||||||
|
if tab_name == "overview":
|
||||||
|
extra["overview"] = get_overview(db)
|
||||||
|
elif tab_name == "firms":
|
||||||
|
extra["firms"] = list_firms(db)
|
||||||
|
elif tab_name == "catalogue":
|
||||||
|
extra["catalogue"] = get_catalogue_summary(db)
|
||||||
|
elif tab_name == "smtp":
|
||||||
|
extra["smtp"] = get_smtp_summary(db)
|
||||||
|
elif tab_name == "storage":
|
||||||
|
extra["storage"] = get_storage_status(db)
|
||||||
|
elif tab_name == "reports":
|
||||||
|
extra["report_cards"] = get_report_cards(db)
|
||||||
|
elif tab_name == "wizards":
|
||||||
|
extra["wizard_cards"] = get_wizard_cards()
|
||||||
|
elif tab_name == "audit-logs":
|
||||||
|
extra["audit_logs"] = get_recent_audit_logs(db)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(template, _base_context(request, db, current_user, **extra))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -23,6 +23,7 @@ from app.modules.email_integration.ui import router as email_integration_router
|
|||||||
from app.modules.domain_management.ui import router as domain_management_router
|
from app.modules.domain_management.ui import router as domain_management_router
|
||||||
from app.modules.notice_cases.ui import router as notice_cases_router
|
from app.modules.notice_cases.ui import router as notice_cases_router
|
||||||
from app.modules.wizards.ui import router as wizards_ui_router
|
from app.modules.wizards.ui import router as wizards_ui_router
|
||||||
|
from app.modules.system_admin_dashboard.ui import router as system_admin_dashboard_router
|
||||||
from app.ui.routes.auth import router as auth_router
|
from app.ui.routes.auth import router as auth_router
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(alerts_ui_router)
|
app.include_router(alerts_ui_router)
|
||||||
app.include_router(notice_cases_router)
|
app.include_router(notice_cases_router)
|
||||||
app.include_router(wizards_ui_router)
|
app.include_router(wizards_ui_router)
|
||||||
|
app.include_router(system_admin_dashboard_router)
|
||||||
app.include_router(work_detail_ui_router)
|
app.include_router(work_detail_ui_router)
|
||||||
app.include_router(clients_ui_router)
|
app.include_router(clients_ui_router)
|
||||||
app.include_router(employees_ui_router)
|
app.include_router(employees_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user