diff --git a/app/modules/system_admin_dashboard/service.py b/app/modules/system_admin_dashboard/service.py index ce07c79..a642bb6 100644 --- a/app/modules/system_admin_dashboard/service.py +++ b/app/modules/system_admin_dashboard/service.py @@ -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]: diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/dashboard.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/dashboard.html index 46b21c6..0c8c2bd 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/dashboard.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/dashboard.html @@ -1,25 +1,28 @@ -{% extends "ui/templates/base/layout.html" %} +{% extends "ui/templates/base/layout.html" %} {% block content %}
-

Platform Utility Centre

-

System Admin Dashboard

-

Monitor firms, service catalogue, SMTP, storage, reports, wizards and audit logs from one place.

+

Platform Control Centre

+

System Admin Dashboard V2

+

Monitor firm readiness, catalogue health, SMTP, storage, billing readiness, reports, wizards and audit logs.

{% 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 @@ })(); {% endblock %} - diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/audit_logs.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/audit_logs.html index c36bc2d..e8271d1 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/audit_logs.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/audit_logs.html @@ -1,15 +1,15 @@
-

Recent Audit Logs

Latest 25 audit events.

+

Recent Audit Logs

Latest 25 audit events with target tenant and IP details.

- + {% for log in audit_logs %} - + {% else %} - + {% endfor %}
TimeUserActionEntityStatus
TimeUserActionEntityTarget TenantIPStatus
{{ log.created_at_utc }}{{ log.actor_email or '-' }}{{ log.action }}{{ log.entity_type }}{% if log.entity_name %} > {{ log.entity_name }}{% endif %}{{ log.status }}
{{ log.created_at_utc }}{{ log.actor_email or '-' }}{{ log.action }}{{ log.entity_type }}{% if log.entity_name %} > {{ log.entity_name }}{% endif %}{{ log.target_tenant_id or '-' }}{{ log.ip_address or '-' }}{{ log.status }}
No audit logs found.
No audit logs found.
-
+
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/billing.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/billing.html new file mode 100644 index 0000000..4b1db0f --- /dev/null +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/billing.html @@ -0,0 +1,17 @@ +
+
+

Billing Readiness

+

Read-only view of existing platform billing and firm billing setup readiness.

+ {% if not billing.available %}
{{ billing.message }}
{% endif %} +
+
+
Platform Plans
{{ billing.plan_count }}
{{ billing.active_plan_count }} active
+
Firm Accounts
{{ billing.audit_firm_account_count }}
{{ billing.firms_without_account }} firms without account
+
Subscriptions
{{ billing.active_subscription_count }}
{{ billing.expired_subscription_count }} expired/suspended/cancelled
+
Firm Billing Settings
{{ billing.firm_billing_settings }}
{{ billing.firms_without_billing_settings }} firms pending
+
+ +
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/catalogue.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/catalogue.html index 0c77175..ccb5d22 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/catalogue.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/catalogue.html @@ -1,27 +1,33 @@
-
+
Categories
{{ catalogue.category_count }}
Services
{{ catalogue.service_count }}
{{ catalogue.active_service_count }} active
+
Inactive
{{ catalogue.inactive_service_count }}
Default Tasks
{{ catalogue.default_task_count }}
Due Rules
{{ catalogue.due_rule_count }}
+
No Tasks
{{ catalogue.services_without_tasks_count }}
+ {% if catalogue.services_without_tasks_preview %} +

Services without default tasks

{% for service in catalogue.services_without_tasks_preview %}{{ service.code }} - {{ service.name }}{% endfor %}
+ {% endif %} +
-

Service Catalogue Readiness

First 100 catalogue services with default task/rule counts.

+

Service Catalogue Readiness

First 150 catalogue services with default task/rule counts.

- + {% for service in catalogue.services %} - + {% else %} - + {% endfor %}
CodeServiceCategoryRecurrenceTasksRulesStatus
CodeServiceCategoryRecurrenceTasksRulesReadinessStatus
{{ service.code }}{{ service.name }}{{ service.category }}{{ service.recurrence_type }}{{ service.task_count }}{{ service.rule_count }}{% if service.is_active %}Active{% else %}Inactive{% endif %}
{{ service.code }}{{ service.name }}{{ service.category }}{{ service.recurrence_type }}{{ service.task_count }}{{ service.rule_count }}{{ service.readiness }}{% if service.is_active %}Active{% else %}Inactive{% endif %}
No service catalogue found.
No services found.
-
+
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firm_setup_health.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firm_setup_health.html new file mode 100644 index 0000000..aaac563 --- /dev/null +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firm_setup_health.html @@ -0,0 +1,37 @@ +
+
+
Firms
{{ health.total }}
+
Ready
{{ health.ready }}
+
Incomplete
{{ health.incomplete }}
+
No Branch
{{ health.missing_branch }}
+
No FY
{{ health.missing_fy }}
+
No Services
{{ health.missing_services }}
+
+ +
+

Firm Setup Health Checklist

A firm is ready when Branch, Firm Admin, FY, selected services and users are available.

+
+ + + + {% for firm in health.rows %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
FirmBranchAdminFYServicesUsersBrandingBillingStatusMissing
{{ firm.name }}
{{ firm.code }}
{% if firm.branch_ok %}Done{% else %}Missing{% endif %}
{{ firm.branch_count }}
{% if firm.admin_ok %}Done{% else %}Missing{% endif %}
{{ firm.primary_admin }}
{% if firm.fy_ok %}Done{% else %}Missing{% endif %}
{{ firm.fy_count }} FY{% if firm.current_fy_ok %} / current set{% endif %}
{% if firm.services_ok %}Done{% else %}Missing{% endif %}
{{ firm.selected_service_count }}
{% if firm.users_ok %}Done{% else %}Missing{% endif %}
{{ firm.user_count }}
{% if firm.branding_ok %}Started{% else %}Pending{% endif %}{% if firm.billing_ok %}Ready{% else %}Pending{% endif %}{{ firm.status }} {{ firm.score }}/{{ firm.score_total }}{{ firm.missing_text }}
No firms found.
+
+
+
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firms.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firms.html index 1e8e935..f929815 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firms.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/firms.html @@ -1,28 +1,29 @@
-

Firms / Tenants

Latest firms created on the platform.

+

Firms / Tenants

Firm readiness snapshot with admin, branch, FY and service counts.

+ Create Firm
- - - + {% for firm in firms %} - - + + + + + {% else %} - + {% endfor %}
CodeFirmStatusFirm AdminBranchesUsersAction
CodeFirmStatusPrimary BranchFirm AdminBranchesUsersFYServicesAction
{{ firm.code }}
{{ firm.name }}
{{ firm.firm_type or '-' }}{% if firm.contact_email %} > {{ firm.contact_email }}{% endif %}
{% if firm.is_active %}Active{% else %}Inactive{% endif %}
{{ firm.name }}
{{ firm.firm_type or '-' }}{% if firm.contact_email %} > {{ firm.contact_email }}{% endif %}
{% if firm.is_active %}Active{% else %}Inactive{% endif %}{% if firm.pending_invites %}
{{ firm.pending_invites }} invite pending
{% endif %}
{{ firm.primary_branch }} {{ firm.primary_admin }} {{ firm.branch_count }} {{ firm.user_count }}{{ firm.fy_count }}{{ firm.selected_service_count }} Open Settings
No firms found.
No firms found.
-
+ \ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/overview.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/overview.html index 685862f..504642b 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/overview.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/overview.html @@ -1,27 +1,36 @@
-
Total Firms
{{ overview.total_firms }}
-
Active Firms
{{ overview.active_firms }}
-
Pending Invites
{{ overview.pending_invites }}
-
Platform SMTP
{% if overview.smtp.configured and overview.smtp.active %}Ready{% else %}Needs Setup{% endif %}
{{ overview.smtp.message }}
+
Total Firms
{{ overview.total_firms }}
{{ overview.active_firms }} active / {{ overview.inactive_firms }} inactive
+
Setup Incomplete
{{ overview.setup_incomplete_firms }}
Firms needing setup action
+
Pending Admin Invites
{{ overview.pending_invites }}
Firm Admin password/invite pending
+
Platform SMTP
{% if overview.smtp.configured and overview.smtp.active and overview.smtp.send_auth_emails %}Ready{% else %}Needs Setup{% endif %}
{{ overview.smtp.message }}
Service Catalogue
{{ overview.service_count }}
{{ overview.active_service_count }} active services
-
Default Task Templates
{{ overview.default_task_count }}
{{ overview.due_rule_count }} due-date rules
-
Storage Used
{{ overview.storage.used_display }}
Branding files: {{ overview.storage.branding_file_count }}
-
Inactive Firms
{{ overview.inactive_firms }}
Use Firms tab for details
+
Default Task Templates
{{ overview.default_task_count }}
{{ overview.services_without_tasks }} services without tasks
+
Storage
{{ overview.storage.status_label }}
{{ overview.storage.used_display }} used
+
Billing Readiness
{{ overview.billing.active_subscription_count }}
Active platform subscriptions
- +
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/reports.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/reports.html index 4199255..6324ad7 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/reports.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/reports.html @@ -1,9 +1,16 @@ -
- {% for card in report_cards %} - -

{{ card.title }}

{{ card.metric }}
-

{{ card.description }}

-
View report
-
+
+ {% for group_name, cards in report_groups.items() %} +
+

{{ group_name }}

+ +
{% endfor %} -
+
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/smtp.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/smtp.html index 44ca9c6..9a9d004 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/smtp.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/smtp.html @@ -3,10 +3,14 @@

Platform SMTP

Used for Firm Creation Wizard invites and system-level emails.

-
Status
{% if smtp.configured and smtp.active %}Ready{% else %}Needs Setup{% endif %}
+
Status
{% if smtp.configured and smtp.active and smtp.send_auth_emails %}Ready{% else %}Needs Setup{% endif %}
Host / Port
{{ smtp.host or '-' }}{% if smtp.port %}:{{ smtp.port }}{% endif %}
From Email
{{ smtp.from_email or '-' }}
+
From Name
{{ smtp.from_name or '-' }}
+
Reply To
{{ smtp.reply_to_email or '-' }}
Security
{{ smtp.security or '-' }}
+
Active
{% if smtp.active %}Yes{% else %}No{% endif %}
+
Auth Emails
{% if smtp.send_auth_emails %}Enabled{% else %}Disabled{% endif %}

{{ smtp.message }}

@@ -15,4 +19,4 @@

Configure or test platform SMTP from the existing settings page.

Open Platform SMTP - + \ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/storage.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/storage.html index a385f0f..11fa3dc 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/storage.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/storage.html @@ -8,8 +8,8 @@

Persistent Storage Paths

-
Storage root: {{ storage.storage_root }}
-
Branding root: {{ storage.branding_root }}
+
Storage root: {{ storage.storage_root }} {% if storage.storage_root_exists %}Ready{% else %}Missing{% endif %}
+
Branding root: {{ storage.branding_root }} {% if storage.branding_root_exists %}Ready{% else %}Missing{% endif %}
- + \ No newline at end of file diff --git a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/wizards.html b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/wizards.html index a7dffbd..a79abcd 100644 --- a/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/wizards.html +++ b/app/modules/system_admin_dashboard/templates/system_admin_dashboard/partials/wizards.html @@ -1,9 +1,16 @@ -
- {% for card in wizard_cards %} - -

{{ card.title }}

-

{{ card.description }}

-
{{ card.action }}
-
+
+ {% for group_name, cards in wizard_groups.items() %} +
+

{{ group_name }}

+
+ {% for card in cards %} + +

{{ card.title }}

+

{{ card.description }}

+
{{ card.action }}
+
+ {% endfor %} +
+
{% endfor %} -
+
\ No newline at end of file diff --git a/app/modules/system_admin_dashboard/ui.py b/app/modules/system_admin_dashboard/ui.py index 9ccde21..cf753f8 100644 --- a/app/modules/system_admin_dashboard/ui.py +++ b/app/modules/system_admin_dashboard/ui.py @@ -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)