Upgrade system admin dashboard to v2
This commit is contained in:
@@ -10,14 +10,20 @@ 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.core.tenancy.models import Branch, FinancialYear, Tenant, YearBackupExport
|
||||
from app.modules.services.models import (
|
||||
FirmServiceSelection,
|
||||
ServiceCatalogue,
|
||||
ServiceCategory,
|
||||
ServiceDefaultTaskTemplate,
|
||||
ServiceDueDateRule,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.modules.billing.models import BillingSettings
|
||||
except Exception: # pragma: no cover
|
||||
BillingSettings = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.modules.core.audit.models import AuditLog
|
||||
except Exception: # pragma: no cover - keeps dashboard usable if audit module is unavailable
|
||||
@@ -28,6 +34,19 @@ try:
|
||||
except Exception: # pragma: no cover - dashboard falls back to not configured
|
||||
PlatformEmailSetting = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from app.modules.platform_billing.models import (
|
||||
PlatformBillingAccount,
|
||||
PlatformInvoice,
|
||||
PlatformPlan,
|
||||
PlatformSubscription,
|
||||
)
|
||||
except Exception: # pragma: no cover - platform billing can remain optional
|
||||
PlatformBillingAccount = None # type: ignore[assignment]
|
||||
PlatformInvoice = None # type: ignore[assignment]
|
||||
PlatformPlan = None # type: ignore[assignment]
|
||||
PlatformSubscription = None # type: ignore[assignment]
|
||||
|
||||
|
||||
STORAGE_ROOT = Path("/app/data/storage")
|
||||
BRANDING_ROOT = STORAGE_ROOT / "uploads" / "branding"
|
||||
@@ -55,10 +74,7 @@ def is_system_admin(db: Session, user: User | None) -> bool:
|
||||
|
||||
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()
|
||||
]
|
||||
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:
|
||||
@@ -76,6 +92,22 @@ def _pending_firm_admin_invite_count(db: Session) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _pending_firm_admin_invites_by_tenant(db: Session) -> dict[int, int]:
|
||||
role_ids = _firm_admin_role_ids(db)
|
||||
if not role_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(User.tenant_id, 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))
|
||||
.group_by(User.tenant_id)
|
||||
).all()
|
||||
return {int(tenant_id): int(count or 0) for tenant_id, count in rows if tenant_id is not None}
|
||||
|
||||
|
||||
def _platform_smtp_status(db: Session) -> dict[str, Any]:
|
||||
if PlatformEmailSetting is None:
|
||||
return {
|
||||
@@ -85,8 +117,10 @@ def _platform_smtp_status(db: Session) -> dict[str, Any]:
|
||||
"port": None,
|
||||
"from_email": None,
|
||||
"from_name": None,
|
||||
"reply_to_email": None,
|
||||
"security": None,
|
||||
"message": "Platform SMTP model not available. Apply W2 SMTP migration/package first.",
|
||||
"send_auth_emails": False,
|
||||
"message": "Platform SMTP model not available. Apply the platform SMTP package/migration first.",
|
||||
}
|
||||
setting = db.execute(select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc())).scalars().first()
|
||||
if not setting:
|
||||
@@ -97,19 +131,25 @@ def _platform_smtp_status(db: Session) -> dict[str, Any]:
|
||||
"port": None,
|
||||
"from_email": None,
|
||||
"from_name": None,
|
||||
"reply_to_email": None,
|
||||
"security": None,
|
||||
"send_auth_emails": False,
|
||||
"message": "Platform SMTP is not configured.",
|
||||
}
|
||||
configured = bool(setting.smtp_host and setting.smtp_port and setting.from_email)
|
||||
active = bool(getattr(setting, "is_active", False))
|
||||
send_auth_emails = bool(getattr(setting, "send_auth_emails", False))
|
||||
return {
|
||||
"configured": configured,
|
||||
"active": bool(getattr(setting, "is_active", False)),
|
||||
"active": active,
|
||||
"host": setting.smtp_host,
|
||||
"port": setting.smtp_port,
|
||||
"from_email": setting.from_email,
|
||||
"from_name": setting.from_name,
|
||||
"reply_to_email": getattr(setting, "reply_to_email", None),
|
||||
"security": setting.smtp_security,
|
||||
"message": "Platform SMTP is ready." if configured and setting.is_active else "Platform SMTP is saved but not fully ready.",
|
||||
"send_auth_emails": send_auth_emails,
|
||||
"message": "Platform SMTP is ready for firm invites." if configured and active and send_auth_emails else "Platform SMTP needs attention before system emails are fully ready.",
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +191,73 @@ def format_bytes(size: int) -> str:
|
||||
return f"{size} B"
|
||||
|
||||
|
||||
def _count_distinct_tenants(rows: list[tuple[Any, Any]]) -> int:
|
||||
return len({int(tenant_id) for tenant_id, _ in rows if tenant_id is not None})
|
||||
|
||||
|
||||
def _common_count_maps(db: Session) -> dict[str, dict[int, int]]:
|
||||
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())
|
||||
fy_counts = dict(db.execute(select(FinancialYear.tenant_id, func.count(FinancialYear.id)).group_by(FinancialYear.tenant_id)).all())
|
||||
current_fy_counts = dict(db.execute(select(FinancialYear.tenant_id, func.count(FinancialYear.id)).where(FinancialYear.is_current.is_(True)).group_by(FinancialYear.tenant_id)).all())
|
||||
selected_service_counts = dict(db.execute(select(FirmServiceSelection.tenant_id, func.count(FirmServiceSelection.id)).where(FirmServiceSelection.is_enabled.is_(True)).group_by(FirmServiceSelection.tenant_id)).all())
|
||||
billing_setting_counts: dict[int, int] = {}
|
||||
if BillingSettings is not None:
|
||||
billing_setting_counts = dict(db.execute(select(BillingSettings.tenant_id, func.count(BillingSettings.id)).group_by(BillingSettings.tenant_id)).all())
|
||||
return {
|
||||
"branch": {int(k): int(v or 0) for k, v in branch_counts.items() if k is not None},
|
||||
"user": {int(k): int(v or 0) for k, v in user_counts.items() if k is not None},
|
||||
"fy": {int(k): int(v or 0) for k, v in fy_counts.items() if k is not None},
|
||||
"current_fy": {int(k): int(v or 0) for k, v in current_fy_counts.items() if k is not None},
|
||||
"selected_service": {int(k): int(v or 0) for k, v in selected_service_counts.items() if k is not None},
|
||||
"billing_setting": {int(k): int(v or 0) for k, v in billing_setting_counts.items() if k is not None},
|
||||
}
|
||||
|
||||
|
||||
def _primary_branch_names(db: Session) -> dict[int, str]:
|
||||
branches = db.execute(select(Branch).order_by(Branch.tenant_id.asc(), Branch.is_head_office.desc(), Branch.id.asc())).scalars().all()
|
||||
result: dict[int, str] = {}
|
||||
for branch in branches:
|
||||
if branch.tenant_id not in result:
|
||||
result[branch.tenant_id] = branch.name or branch.code
|
||||
return result
|
||||
|
||||
|
||||
def _primary_firm_admins(db: Session) -> dict[int, str]:
|
||||
role_ids = _firm_admin_role_ids(db)
|
||||
primary_admins: dict[int, str] = {}
|
||||
if not role_ids:
|
||||
return primary_admins
|
||||
rows = db.execute(
|
||||
select(User.tenant_id, User.email, User.full_name, User.must_change_password, User.is_active)
|
||||
.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, must_change_password, is_active in rows:
|
||||
if tenant_id not in primary_admins:
|
||||
label = full_name or email
|
||||
if must_change_password:
|
||||
label = f"{label} (invite pending)"
|
||||
elif not is_active:
|
||||
label = f"{label} (inactive)"
|
||||
primary_admins[int(tenant_id)] = label
|
||||
return primary_admins
|
||||
|
||||
|
||||
def _firm_admin_counts(db: Session) -> dict[int, int]:
|
||||
role_ids = _firm_admin_role_ids(db)
|
||||
if not role_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(User.tenant_id, func.count(func.distinct(User.id)))
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.where(UserRole.role_id.in_(role_ids))
|
||||
.group_by(User.tenant_id)
|
||||
).all()
|
||||
return {int(tenant_id): int(count or 0) for tenant_id, count in rows if tenant_id is not None}
|
||||
|
||||
|
||||
def get_storage_status(db: Session) -> dict[str, Any]:
|
||||
root_exists = STORAGE_ROOT.exists()
|
||||
branding_exists = BRANDING_ROOT.exists()
|
||||
@@ -166,6 +273,102 @@ def get_storage_status(db: Session) -> dict[str, Any]:
|
||||
"backup_export_count": backup_exports,
|
||||
"used_bytes": root_size,
|
||||
"used_display": format_bytes(root_size),
|
||||
"status_label": "Ready" if root_exists and branding_exists else "Needs attention",
|
||||
}
|
||||
|
||||
|
||||
def get_firm_setup_health(db: Session, limit: int = 200) -> dict[str, Any]:
|
||||
tenants = db.execute(select(Tenant).order_by(Tenant.id.desc()).limit(limit)).scalars().all()
|
||||
maps = _common_count_maps(db)
|
||||
admin_counts = _firm_admin_counts(db)
|
||||
pending_invites = _pending_firm_admin_invites_by_tenant(db)
|
||||
primary_admins = _primary_firm_admins(db)
|
||||
primary_branches = _primary_branch_names(db)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
incomplete = 0
|
||||
missing_branch = 0
|
||||
missing_admin = 0
|
||||
missing_fy = 0
|
||||
missing_services = 0
|
||||
missing_branding = 0
|
||||
|
||||
for tenant in tenants:
|
||||
tid = int(tenant.id)
|
||||
branch_ok = maps["branch"].get(tid, 0) > 0
|
||||
admin_ok = admin_counts.get(tid, 0) > 0
|
||||
fy_ok = maps["fy"].get(tid, 0) > 0
|
||||
current_fy_ok = maps["current_fy"].get(tid, 0) > 0
|
||||
services_ok = maps["selected_service"].get(tid, 0) > 0
|
||||
users_ok = maps["user"].get(tid, 0) > 0
|
||||
branding_ok = bool((tenant.display_name or tenant.name) and (tenant.logo_path or tenant.primary_color or tenant.contact_email))
|
||||
billing_ok = maps["billing_setting"].get(tid, 0) > 0
|
||||
|
||||
checks = [branch_ok, admin_ok, fy_ok, services_ok, users_ok]
|
||||
score = sum(1 for item in checks if item)
|
||||
status = "Ready" if all(checks) else "Incomplete"
|
||||
if not all(checks):
|
||||
incomplete += 1
|
||||
if not branch_ok:
|
||||
missing_branch += 1
|
||||
if not admin_ok:
|
||||
missing_admin += 1
|
||||
if not fy_ok:
|
||||
missing_fy += 1
|
||||
if not services_ok:
|
||||
missing_services += 1
|
||||
if not branding_ok:
|
||||
missing_branding += 1
|
||||
|
||||
missing = []
|
||||
if not branch_ok:
|
||||
missing.append("Branch")
|
||||
if not admin_ok:
|
||||
missing.append("Firm Admin")
|
||||
if not fy_ok:
|
||||
missing.append("FY")
|
||||
if not services_ok:
|
||||
missing.append("Services")
|
||||
if not users_ok:
|
||||
missing.append("Users")
|
||||
|
||||
rows.append({
|
||||
"id": tid,
|
||||
"code": tenant.code,
|
||||
"name": tenant.display_name or tenant.name,
|
||||
"is_active": tenant.is_active,
|
||||
"branch_ok": branch_ok,
|
||||
"admin_ok": admin_ok,
|
||||
"fy_ok": fy_ok,
|
||||
"current_fy_ok": current_fy_ok,
|
||||
"services_ok": services_ok,
|
||||
"users_ok": users_ok,
|
||||
"branding_ok": branding_ok,
|
||||
"billing_ok": billing_ok,
|
||||
"branch_count": maps["branch"].get(tid, 0),
|
||||
"user_count": maps["user"].get(tid, 0),
|
||||
"fy_count": maps["fy"].get(tid, 0),
|
||||
"selected_service_count": maps["selected_service"].get(tid, 0),
|
||||
"billing_setting_count": maps["billing_setting"].get(tid, 0),
|
||||
"primary_admin": primary_admins.get(tid, "-"),
|
||||
"primary_branch": primary_branches.get(tid, "-"),
|
||||
"pending_invites": pending_invites.get(tid, 0),
|
||||
"score": score,
|
||||
"score_total": len(checks),
|
||||
"status": status,
|
||||
"missing_text": ", ".join(missing) if missing else "None",
|
||||
})
|
||||
|
||||
return {
|
||||
"rows": rows,
|
||||
"total": len(rows),
|
||||
"ready": len(rows) - incomplete,
|
||||
"incomplete": incomplete,
|
||||
"missing_branch": missing_branch,
|
||||
"missing_admin": missing_admin,
|
||||
"missing_fy": missing_fy,
|
||||
"missing_services": missing_services,
|
||||
"missing_branding": missing_branding,
|
||||
}
|
||||
|
||||
|
||||
@@ -181,44 +384,52 @@ def get_overview(db: Session) -> dict[str, Any]:
|
||||
pending_invites = _pending_firm_admin_invite_count(db)
|
||||
storage = get_storage_status(db)
|
||||
smtp = _platform_smtp_status(db)
|
||||
health = get_firm_setup_health(db, limit=500)
|
||||
catalogue = get_catalogue_summary(db)
|
||||
billing = get_billing_readiness(db)
|
||||
|
||||
needs_attention = []
|
||||
if health["missing_branch"]:
|
||||
needs_attention.append({"label": "Firms without branch", "count": health["missing_branch"], "tab": "firm-setup-health"})
|
||||
if health["missing_admin"]:
|
||||
needs_attention.append({"label": "Firms without Firm Admin", "count": health["missing_admin"], "tab": "firm-setup-health"})
|
||||
if health["missing_fy"]:
|
||||
needs_attention.append({"label": "Firms without FY", "count": health["missing_fy"], "tab": "firm-setup-health"})
|
||||
if health["missing_services"]:
|
||||
needs_attention.append({"label": "Firms without selected services", "count": health["missing_services"], "tab": "firm-setup-health"})
|
||||
if pending_invites:
|
||||
needs_attention.append({"label": "Firm Admin invites pending", "count": pending_invites, "tab": "firms"})
|
||||
if catalogue["services_without_tasks_count"]:
|
||||
needs_attention.append({"label": "Services without default tasks", "count": catalogue["services_without_tasks_count"], "tab": "catalogue"})
|
||||
if not (smtp["configured"] and smtp["active"] and smtp["send_auth_emails"]):
|
||||
needs_attention.append({"label": "Platform SMTP not fully ready", "count": 1, "tab": "smtp"})
|
||||
if not (storage["storage_root_exists"] and storage["branding_root_exists"]):
|
||||
needs_attention.append({"label": "Persistent storage path needs attention", "count": 1, "tab": "storage"})
|
||||
|
||||
return {
|
||||
"total_firms": total_firms,
|
||||
"active_firms": active_firms,
|
||||
"inactive_firms": inactive_firms,
|
||||
"setup_incomplete_firms": health["incomplete"],
|
||||
"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,
|
||||
"services_without_tasks": catalogue["services_without_tasks_count"],
|
||||
"storage": storage,
|
||||
"smtp": smtp,
|
||||
"billing": billing,
|
||||
"needs_attention": needs_attention,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
maps = _common_count_maps(db)
|
||||
primary_admins = _primary_firm_admins(db)
|
||||
primary_branches = _primary_branch_names(db)
|
||||
pending_invites = _pending_firm_admin_invites_by_tenant(db)
|
||||
|
||||
tenants = db.execute(select(Tenant).order_by(Tenant.id.desc()).limit(limit)).scalars().all()
|
||||
return [
|
||||
@@ -229,9 +440,13 @@ def list_firms(db: Session, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"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),
|
||||
"branch_count": maps["branch"].get(tenant.id, 0),
|
||||
"user_count": maps["user"].get(tenant.id, 0),
|
||||
"fy_count": maps["fy"].get(tenant.id, 0),
|
||||
"selected_service_count": maps["selected_service"].get(tenant.id, 0),
|
||||
"primary_admin": primary_admins.get(tenant.id, "-"),
|
||||
"primary_branch": primary_branches.get(tenant.id, "-"),
|
||||
"pending_invites": pending_invites.get(tenant.id, 0),
|
||||
"contact_email": tenant.contact_email,
|
||||
}
|
||||
for tenant in tenants
|
||||
@@ -240,7 +455,7 @@ def list_firms(db: Session, limit: int = 100) -> list[dict[str, Any]]:
|
||||
|
||||
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()
|
||||
services = db.execute(select(ServiceCatalogue).order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()).limit(150)).scalars().all()
|
||||
task_counts = dict(
|
||||
db.execute(
|
||||
select(ServiceDefaultTaskTemplate.service_catalogue_id, func.count(ServiceDefaultTaskTemplate.id))
|
||||
@@ -253,13 +468,27 @@ def get_catalogue_summary(db: Session) -> dict[str, Any]:
|
||||
.group_by(ServiceDueDateRule.service_catalogue_id)
|
||||
).all()
|
||||
)
|
||||
services_without_tasks = [service for service in services if int(task_counts.get(service.id, 0) or 0) == 0]
|
||||
inactive_services = [service for service in services if not service.is_active]
|
||||
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))),
|
||||
"inactive_service_count": scalar_count(db, select(func.count(ServiceCatalogue.id)).where(ServiceCatalogue.is_active.is_(False))),
|
||||
"default_task_count": scalar_count(db, select(func.count(ServiceDefaultTaskTemplate.id))),
|
||||
"due_rule_count": scalar_count(db, select(func.count(ServiceDueDateRule.id))),
|
||||
"services_without_tasks_count": scalar_count(
|
||||
db,
|
||||
select(func.count(ServiceCatalogue.id)).outerjoin(
|
||||
ServiceDefaultTaskTemplate,
|
||||
ServiceDefaultTaskTemplate.service_catalogue_id == ServiceCatalogue.id,
|
||||
).group_by(ServiceCatalogue.id).having(func.count(ServiceDefaultTaskTemplate.id) == 0)
|
||||
) if False else len(services_without_tasks),
|
||||
"categories": categories,
|
||||
"services_without_tasks_preview": [
|
||||
{"id": service.id, "code": service.service_code, "name": service.service_name}
|
||||
for service in services_without_tasks[:10]
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"id": service.id,
|
||||
@@ -271,6 +500,7 @@ def get_catalogue_summary(db: Session) -> dict[str, Any]:
|
||||
"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),
|
||||
"readiness": "Ready" if int(task_counts.get(service.id, 0) or 0) > 0 else "No default tasks",
|
||||
}
|
||||
for service in services
|
||||
],
|
||||
@@ -281,75 +511,160 @@ def get_smtp_summary(db: Session) -> dict[str, Any]:
|
||||
return _platform_smtp_status(db)
|
||||
|
||||
|
||||
def get_report_cards(db: Session) -> list[dict[str, str]]:
|
||||
def get_billing_readiness(db: Session) -> dict[str, Any]:
|
||||
firm_count = scalar_count(db, select(func.count(Tenant.id)))
|
||||
firm_billing_settings = 0
|
||||
if BillingSettings is not None:
|
||||
firm_billing_settings = scalar_count(db, select(func.count(func.distinct(BillingSettings.tenant_id))))
|
||||
|
||||
if PlatformPlan is None or PlatformBillingAccount is None or PlatformSubscription is None:
|
||||
return {
|
||||
"available": False,
|
||||
"plan_count": 0,
|
||||
"active_plan_count": 0,
|
||||
"audit_firm_account_count": 0,
|
||||
"active_subscription_count": 0,
|
||||
"expired_subscription_count": 0,
|
||||
"firms_without_account": firm_count,
|
||||
"firm_billing_settings": firm_billing_settings,
|
||||
"firms_without_billing_settings": max(firm_count - firm_billing_settings, 0),
|
||||
"message": "Platform billing models are not available in this deployment.",
|
||||
}
|
||||
|
||||
plan_count = scalar_count(db, select(func.count(PlatformPlan.id)))
|
||||
active_plan_count = scalar_count(db, select(func.count(PlatformPlan.id)).where(PlatformPlan.is_active.is_(True)))
|
||||
audit_firm_account_count = scalar_count(db, select(func.count(PlatformBillingAccount.id)).where(PlatformBillingAccount.account_type == "AUDIT_FIRM"))
|
||||
active_subscription_count = scalar_count(db, select(func.count(PlatformSubscription.id)).where(PlatformSubscription.status == "ACTIVE"))
|
||||
expired_subscription_count = scalar_count(db, select(func.count(PlatformSubscription.id)).where(PlatformSubscription.status.in_(["EXPIRED", "CANCELLED", "SUSPENDED"])))
|
||||
invoice_count = 0
|
||||
if PlatformInvoice is not None:
|
||||
invoice_count = scalar_count(db, select(func.count(PlatformInvoice.id)))
|
||||
return {
|
||||
"available": True,
|
||||
"plan_count": plan_count,
|
||||
"active_plan_count": active_plan_count,
|
||||
"audit_firm_account_count": audit_firm_account_count,
|
||||
"active_subscription_count": active_subscription_count,
|
||||
"expired_subscription_count": expired_subscription_count,
|
||||
"platform_invoice_count": invoice_count,
|
||||
"firms_without_account": max(firm_count - audit_firm_account_count, 0),
|
||||
"firm_billing_settings": firm_billing_settings,
|
||||
"firms_without_billing_settings": max(firm_count - firm_billing_settings, 0),
|
||||
"message": "Platform billing module is available." if plan_count else "Platform billing exists but plans may need setup.",
|
||||
}
|
||||
|
||||
|
||||
def get_report_cards(db: Session) -> dict[str, 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",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"Firm Reports": [
|
||||
{
|
||||
"title": "Firm Setup Completeness",
|
||||
"description": "Checklist of branch, Firm Admin, FY, selected services and users.",
|
||||
"metric": f"{overview['setup_incomplete_firms']} incomplete",
|
||||
"href": "/system-admin/dashboard?tab=firm-setup-health",
|
||||
},
|
||||
{
|
||||
"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": "Firm User Count Report",
|
||||
"description": "Review branch, user, FY and service count per firm.",
|
||||
"metric": f"{overview['total_firms']} firms",
|
||||
"href": "/system-admin/dashboard?tab=firms",
|
||||
},
|
||||
],
|
||||
"Service Reports": [
|
||||
{
|
||||
"title": "Catalogue Readiness",
|
||||
"description": "Check service categories, services, templates and due-date rules.",
|
||||
"metric": f"{overview['service_count']} services",
|
||||
"href": "/system-admin/dashboard?tab=catalogue",
|
||||
},
|
||||
{
|
||||
"title": "Services Without Default Tasks",
|
||||
"description": "Services that may not generate execution tasks correctly.",
|
||||
"metric": f"{overview['services_without_tasks']} services",
|
||||
"href": "/system-admin/dashboard?tab=catalogue",
|
||||
},
|
||||
],
|
||||
"System Reports": [
|
||||
{
|
||||
"title": "Platform SMTP Status",
|
||||
"description": "Check whether system emails can be sent for firm invites and 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_wizard_cards() -> dict[str, list[dict[str, str]]]:
|
||||
return {
|
||||
"Setup Wizards": [
|
||||
{
|
||||
"title": "Create Firm Wizard",
|
||||
"description": "Create tenant, 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",
|
||||
},
|
||||
],
|
||||
"Catalogue Wizards": [
|
||||
{
|
||||
"title": "Service Catalogue",
|
||||
"description": "Manage master service catalogue.",
|
||||
"href": "/services/catalogue",
|
||||
"action": "Open Catalogue",
|
||||
},
|
||||
{
|
||||
"title": "Default Task Templates",
|
||||
"description": "Manage default task templates used by firms.",
|
||||
"href": "/services/default-templates",
|
||||
"action": "Open Templates",
|
||||
},
|
||||
{
|
||||
"title": "Bulk Service Imports",
|
||||
"description": "Use existing bulk import screens for service master and default tasks.",
|
||||
"href": "/services/bulk-imports",
|
||||
"action": "Open Imports",
|
||||
},
|
||||
],
|
||||
"Control Shortcuts": [
|
||||
{
|
||||
"title": "Platform Billing",
|
||||
"description": "Open existing platform billing module.",
|
||||
"href": "/platform-billing",
|
||||
"action": "Open Billing",
|
||||
},
|
||||
{
|
||||
"title": "Audit Logs",
|
||||
"description": "Review system audit logs.",
|
||||
"href": "/audit-logs",
|
||||
"action": "Open Logs",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_recent_audit_logs(db: Session, limit: int = 25) -> list[Any]:
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% extends "ui/templates/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>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Platform Control Centre</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">System Admin Dashboard V2</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Monitor firm readiness, catalogue health, SMTP, storage, billing readiness, reports, wizards and audit logs.</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>
|
||||
<a href="/platform-billing" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Platform Billing</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tabs = [
|
||||
('overview', 'Overview'),
|
||||
('firms', 'Firms'),
|
||||
('firm-setup-health', 'Setup Health'),
|
||||
('catalogue', 'Service Catalogue'),
|
||||
('smtp', 'Platform SMTP'),
|
||||
('storage', 'Storage'),
|
||||
('billing', 'Billing Readiness'),
|
||||
('reports', 'Reports'),
|
||||
('wizards', 'Wizards'),
|
||||
('audit-logs', 'Audit Logs')
|
||||
@@ -95,4 +98,3 @@
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
+5
-5
@@ -1,15 +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="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 with target tenant and IP details.</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>
|
||||
<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">Target Tenant</th><th class="px-5 py-3">IP</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>
|
||||
<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">{{ log.target_tenant_id or '-' }}</td><td class="px-5 py-3">{{ log.ip_address or '-' }}</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>
|
||||
<tr><td colspan="7" class="px-5 py-8 text-center text-slate-500">No audit logs found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h2 class="text-lg font-bold text-slate-900">Billing Readiness</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Read-only view of existing platform billing and firm billing setup readiness.</p>
|
||||
{% if not billing.available %}<div class="mt-4 rounded-2xl border border-amber-100 bg-amber-50 p-4 text-sm font-semibold text-amber-800">{{ billing.message }}</div>{% endif %}
|
||||
</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 text-slate-500">Platform Plans</div><div class="mt-2 text-2xl font-bold">{{ billing.plan_count }}</div><div class="text-xs text-slate-500">{{ billing.active_plan_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">Firm Accounts</div><div class="mt-2 text-2xl font-bold">{{ billing.audit_firm_account_count }}</div><div class="text-xs text-slate-500">{{ billing.firms_without_account }} firms without account</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">Subscriptions</div><div class="mt-2 text-2xl font-bold text-emerald-700">{{ billing.active_subscription_count }}</div><div class="text-xs text-slate-500">{{ billing.expired_subscription_count }} expired/suspended/cancelled</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">Firm Billing Settings</div><div class="mt-2 text-2xl font-bold">{{ billing.firm_billing_settings }}</div><div class="text-xs text-slate-500">{{ billing.firms_without_billing_settings }} firms pending</div></div>
|
||||
</div>
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-bold text-slate-900">Actions</h3>
|
||||
<div class="mt-4 flex flex-wrap gap-2"><a href="/platform-billing" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Platform Billing</a><a href="/billing/settings" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Firm Billing Settings</a></div>
|
||||
</div>
|
||||
</div>
|
||||
+12
-6
@@ -1,27 +1,33 @@
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<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">Inactive</div><div class="mt-2 text-2xl font-bold">{{ catalogue.inactive_service_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">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 class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">No Tasks</div><div class="mt-2 text-2xl font-bold {% if catalogue.services_without_tasks_count %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ catalogue.services_without_tasks_count }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if catalogue.services_without_tasks_preview %}
|
||||
<div class="rounded-3xl border border-amber-100 bg-amber-50 p-5 shadow-soft"><h3 class="font-bold text-amber-900">Services without default tasks</h3><div class="mt-3 flex flex-wrap gap-2">{% for service in catalogue.services_without_tasks_preview %}<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-amber-800">{{ service.code }} - {{ service.name }}</span>{% endfor %}</div></div>
|
||||
{% endif %}
|
||||
|
||||
<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><h2 class="text-lg font-bold text-slate-900">Service Catalogue Readiness</h2><p class="text-sm text-slate-500">First 150 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>
|
||||
<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">Readiness</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>
|
||||
<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.5 py-1 text-xs font-semibold {% if service.task_count %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ service.readiness }}</span></td><td class="px-5 py-3">{% if service.is_active %}<span class="text-emerald-700 font-semibold">Active</span>{% else %}<span class="text-slate-500">Inactive</span>{% endif %}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-5 py-8 text-center text-slate-500">No service catalogue found.</td></tr>
|
||||
<tr><td colspan="8" class="px-5 py-8 text-center text-slate-500">No services found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Firms</div><div class="mt-2 text-2xl font-bold">{{ health.total }}</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">Ready</div><div class="mt-2 text-2xl font-bold text-emerald-700">{{ health.ready }}</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">Incomplete</div><div class="mt-2 text-2xl font-bold text-amber-700">{{ health.incomplete }}</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">No Branch</div><div class="mt-2 text-2xl font-bold">{{ health.missing_branch }}</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">No FY</div><div class="mt-2 text-2xl font-bold">{{ health.missing_fy }}</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">No Services</div><div class="mt-2 text-2xl font-bold">{{ health.missing_services }}</div></div>
|
||||
</div>
|
||||
|
||||
<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">Firm Setup Health Checklist</h2><p class="text-sm text-slate-500">A firm is ready when Branch, Firm Admin, FY, selected services and users are available.</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">Firm</th><th class="px-5 py-3">Branch</th><th class="px-5 py-3">Admin</th><th class="px-5 py-3">FY</th><th class="px-5 py-3">Services</th><th class="px-5 py-3">Users</th><th class="px-5 py-3">Branding</th><th class="px-5 py-3">Billing</th><th class="px-5 py-3">Status</th><th class="px-5 py-3">Missing</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for firm in health.rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-5 py-3"><div class="font-semibold text-slate-900">{{ firm.name }}</div><div class="text-xs text-slate-500">{{ firm.code }}</div></td>
|
||||
<td class="px-5 py-3">{% if firm.branch_ok %}<span class="text-emerald-700 font-semibold">Done</span>{% else %}<span class="text-red-700 font-semibold">Missing</span>{% endif %}<div class="text-xs text-slate-500">{{ firm.branch_count }}</div></td>
|
||||
<td class="px-5 py-3">{% if firm.admin_ok %}<span class="text-emerald-700 font-semibold">Done</span>{% else %}<span class="text-red-700 font-semibold">Missing</span>{% endif %}<div class="text-xs text-slate-500">{{ firm.primary_admin }}</div></td>
|
||||
<td class="px-5 py-3">{% if firm.fy_ok %}<span class="text-emerald-700 font-semibold">Done</span>{% else %}<span class="text-red-700 font-semibold">Missing</span>{% endif %}<div class="text-xs text-slate-500">{{ firm.fy_count }} FY{% if firm.current_fy_ok %} / current set{% endif %}</div></td>
|
||||
<td class="px-5 py-3">{% if firm.services_ok %}<span class="text-emerald-700 font-semibold">Done</span>{% else %}<span class="text-red-700 font-semibold">Missing</span>{% endif %}<div class="text-xs text-slate-500">{{ firm.selected_service_count }}</div></td>
|
||||
<td class="px-5 py-3">{% if firm.users_ok %}<span class="text-emerald-700 font-semibold">Done</span>{% else %}<span class="text-red-700 font-semibold">Missing</span>{% endif %}<div class="text-xs text-slate-500">{{ firm.user_count }}</div></td>
|
||||
<td class="px-5 py-3">{% if firm.branding_ok %}<span class="text-emerald-700 font-semibold">Started</span>{% else %}<span class="text-amber-700 font-semibold">Pending</span>{% endif %}</td>
|
||||
<td class="px-5 py-3">{% if firm.billing_ok %}<span class="text-emerald-700 font-semibold">Ready</span>{% else %}<span class="text-amber-700 font-semibold">Pending</span>{% endif %}</td>
|
||||
<td class="px-5 py-3"><span class="rounded-full px-2.5 py-1 text-xs font-semibold {% if firm.status == 'Ready' %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ firm.status }} {{ firm.score }}/{{ firm.score_total }}</span></td>
|
||||
<td class="px-5 py-3 text-slate-600">{{ firm.missing_text }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="px-5 py-8 text-center text-slate-500">No firms found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+9
-8
@@ -1,28 +1,29 @@
|
||||
<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>
|
||||
<div><h2 class="text-lg font-bold text-slate-900">Firms / Tenants</h2><p class="text-sm text-slate-500">Firm readiness snapshot with admin, branch, FY and service counts.</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>
|
||||
<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">Primary Branch</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 text-right">FY</th><th class="px-5 py-3 text-right">Services</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"><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>{% if firm.pending_invites %}<div class="mt-1 text-xs font-semibold text-amber-700">{{ firm.pending_invites }} invite pending</div>{% endif %}</td>
|
||||
<td class="px-5 py-3 text-slate-700">{{ firm.primary_branch }}</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 text-right font-semibold">{{ firm.fy_count }}</td>
|
||||
<td class="px-5 py-3 text-right font-semibold">{{ firm.selected_service_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>
|
||||
<tr><td colspan="10" class="px-5 py-8 text-center text-slate-500">No firms found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+27
-18
@@ -1,27 +1,36 @@
|
||||
<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 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 class="mt-1 text-xs text-slate-500">{{ overview.active_firms }} active / {{ overview.inactive_firms }} inactive</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">Setup Incomplete</div><div class="mt-2 text-3xl font-bold {% if overview.setup_incomplete_firms %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ overview.setup_incomplete_firms }}</div><div class="mt-1 text-xs text-slate-500">Firms needing setup action</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 Admin Invites</div><div class="mt-2 text-3xl font-bold {% if overview.pending_invites %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ overview.pending_invites }}</div><div class="mt-1 text-xs text-slate-500">Firm Admin password/invite pending</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 and overview.smtp.send_auth_emails %}text-emerald-700{% else %}text-amber-700{% endif %}">{% if overview.smtp.configured and overview.smtp.active and overview.smtp.send_auth_emails %}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 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.services_without_tasks }} services without tasks</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</div><div class="mt-2 text-2xl font-bold {% if overview.storage.storage_root_exists and overview.storage.branding_root_exists %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ overview.storage.status_label }}</div><div class="mt-1 text-xs text-slate-500">{{ overview.storage.used_display }} used</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">Billing Readiness</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ overview.billing.active_subscription_count }}</div><div class="mt-1 text-xs text-slate-500">Active platform subscriptions</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 class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft xl:col-span-2">
|
||||
<div class="flex items-center justify-between gap-3"><div><h2 class="text-lg font-bold text-slate-900">Needs Attention</h2><p class="text-sm text-slate-500">Action-first list for System Admin.</p></div><a href="/system-admin/dashboard?tab=firm-setup-health" class="text-sm font-semibold text-brand-700">Open setup health</a></div>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{% for item in overview.needs_attention %}
|
||||
<a href="/system-admin/dashboard?tab={{ item.tab }}" class="rounded-2xl border border-amber-100 bg-amber-50 p-4 hover:border-amber-200"><div class="flex items-center justify-between gap-3"><span class="font-semibold text-amber-900">{{ item.label }}</span><span class="rounded-full bg-white px-2.5 py-1 text-xs font-bold text-amber-700">{{ item.count }}</span></div></a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-emerald-100 bg-emerald-50 p-4 text-sm font-semibold text-emerald-700 md:col-span-2">No immediate platform setup issues found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-bold text-slate-900">Quick Actions</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<a href="/wizards/system/firm/new" class="block rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-700">+ Create Firm Wizard</a>
|
||||
<a href="/email/platform-smtp" class="block rounded-2xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Platform SMTP Settings</a>
|
||||
<a href="/system-admin/dashboard?tab=reports" class="block rounded-2xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Reports Centre</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+15
-8
@@ -1,9 +1,16 @@
|
||||
<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>
|
||||
<div class="space-y-6">
|
||||
{% for group_name, cards in report_groups.items() %}
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-bold text-slate-900">{{ group_name }}</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for card in 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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
+6
-2
@@ -3,10 +3,14 @@
|
||||
<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">Status</dt><dd class="mt-1 font-bold {% if smtp.configured and smtp.active and smtp.send_auth_emails %}text-emerald-700{% else %}text-amber-700{% endif %}">{% if smtp.configured and smtp.active and smtp.send_auth_emails %}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">From Name</dt><dd class="mt-1 font-semibold text-slate-900">{{ smtp.from_name or '-' }}</dd></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">Reply To</dt><dd class="mt-1 font-semibold text-slate-900">{{ smtp.reply_to_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>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">Active</dt><dd class="mt-1 font-semibold text-slate-900">{% if smtp.active %}Yes{% else %}No{% endif %}</dd></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><dt class="text-xs font-semibold uppercase text-slate-500">Auth Emails</dt><dd class="mt-1 font-semibold text-slate-900">{% if smtp.send_auth_emails %}Enabled{% else %}Disabled{% endif %}</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 text-sm text-slate-600">{{ smtp.message }}</p>
|
||||
</div>
|
||||
@@ -15,4 +19,4 @@
|
||||
<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>
|
||||
</div>
|
||||
+3
-3
@@ -8,8 +8,8 @@
|
||||
<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 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> <span class="ml-2 {% if storage.storage_root_exists %}text-emerald-700{% else %}text-red-700{% endif %}">{% if storage.storage_root_exists %}Ready{% else %}Missing{% endif %}</span></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> <span class="ml-2 {% if storage.branding_root_exists %}text-emerald-700{% else %}text-red-700{% endif %}">{% if storage.branding_root_exists %}Ready{% else %}Missing{% endif %}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+15
-8
@@ -1,9 +1,16 @@
|
||||
<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>
|
||||
<div class="space-y-6">
|
||||
{% for group_name, cards in wizard_groups.items() %}
|
||||
<div>
|
||||
<h2 class="mb-3 text-lg font-bold text-slate-900">{{ group_name }}</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{% for card in 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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,7 +10,9 @@ 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_billing_readiness,
|
||||
get_catalogue_summary,
|
||||
get_firm_setup_health,
|
||||
get_overview,
|
||||
get_recent_audit_logs,
|
||||
get_report_cards,
|
||||
@@ -26,9 +28,11 @@ router = APIRouter(prefix="/system-admin", tags=["system-admin-dashboard-ui"])
|
||||
_ALLOWED_TABS = {
|
||||
"overview",
|
||||
"firms",
|
||||
"firm-setup-health",
|
||||
"catalogue",
|
||||
"smtp",
|
||||
"storage",
|
||||
"billing",
|
||||
"reports",
|
||||
"wizards",
|
||||
"audit-logs",
|
||||
@@ -99,16 +103,20 @@ def dashboard_tab(request: Request, tab_name: str):
|
||||
extra["overview"] = get_overview(db)
|
||||
elif tab_name == "firms":
|
||||
extra["firms"] = list_firms(db)
|
||||
elif tab_name == "firm-setup-health":
|
||||
extra["health"] = get_firm_setup_health(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 == "billing":
|
||||
extra["billing"] = get_billing_readiness(db)
|
||||
elif tab_name == "reports":
|
||||
extra["report_cards"] = get_report_cards(db)
|
||||
extra["report_groups"] = get_report_cards(db)
|
||||
elif tab_name == "wizards":
|
||||
extra["wizard_cards"] = get_wizard_cards()
|
||||
extra["wizard_groups"] = get_wizard_cards()
|
||||
elif tab_name == "audit-logs":
|
||||
extra["audit_logs"] = get_recent_audit_logs(db)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user