diff --git a/app/modules/unified_reports/__init__.py b/app/modules/unified_reports/__init__.py new file mode 100644 index 0000000..9e2c161 --- /dev/null +++ b/app/modules/unified_reports/__init__.py @@ -0,0 +1 @@ +"""Unified role-aware reports centre.""" diff --git a/app/modules/unified_reports/service.py b/app/modules/unified_reports/service.py new file mode 100644 index 0000000..31f2e93 --- /dev/null +++ b/app/modules/unified_reports/service.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +SYSTEM_ADMIN = {"System Admin", "Super Admin"} +FIRM_ADMIN = {"Firm Admin"} +PARTNER = {"Partner"} +MANAGER = {"Manager", "Branch Manager"} +STAFF = {"Staff", "Employee"} +CLIENT = {"Client"} +CONSULTANT = {"Consultant"} + + +@dataclass(frozen=True) +class ReportCard: + title: str + description: str + href: str + group: str + roles: tuple[str, ...] + badge: str = "Open" + + +def _role_set(roles: Iterable[str] | None) -> set[str]: + return {str(r).strip() for r in (roles or []) if str(r).strip()} + + +def _has_any(user_roles: set[str], allowed: set[str]) -> bool: + return bool(user_roles.intersection(allowed)) + + +def can_access_reports(roles: Iterable[str] | None, permissions: Iterable[str] | None = None) -> bool: + role_set = _role_set(roles) + permission_set = set(permissions or []) + if role_set.intersection(SYSTEM_ADMIN | FIRM_ADMIN | PARTNER | MANAGER | STAFF | CLIENT | CONSULTANT): + return True + return bool(permission_set.intersection({ + "system.settings.view", + "users.view", + "clients.view", + "services.view", + "billing.view", + "employees.ess.view", + "employees.work.view_self", + "consultants.view", + })) + + +def _all_cards() -> list[ReportCard]: + return [ + # System Admin + ReportCard("Firm Setup Completeness", "Review firm, branch, FY, admin invite, services and branding readiness.", "/system-admin/dashboard?tab=firm-setup-health", "system", ("System Admin",), "Dashboard"), + ReportCard("Firm List / Tenant Report", "Open platform firm list with branch, user and setup summary.", "/system-admin/dashboard?tab=firms", "system", ("System Admin",), "Dashboard"), + ReportCard("Service Catalogue Readiness", "Check service categories, catalogue items and default task template readiness.", "/system-admin/dashboard?tab=catalogue", "system", ("System Admin",), "Dashboard"), + ReportCard("Platform SMTP Status", "Review platform SMTP configuration used for firm invites and system emails.", "/system-admin/dashboard?tab=smtp", "system", ("System Admin",), "Dashboard"), + ReportCard("Storage Status", "Review persistent storage, branding storage and backup storage readiness.", "/system-admin/dashboard?tab=storage", "system", ("System Admin",), "Dashboard"), + ReportCard("Platform Audit Logs", "Open audit log trail for platform-level actions.", "/system-admin/dashboard?tab=audit-logs", "audit", ("System Admin",), "Dashboard"), + ReportCard("Platform Billing Readiness", "Review platform billing and subscription readiness indicators.", "/system-admin/dashboard?tab=billing", "billing", ("System Admin",), "Dashboard"), + + # Firm Admin + ReportCard("Branch Setup Report", "Review branches, primary branch readiness and branch setup shortcuts.", "/firm-admin/dashboard?tab=branches", "system", ("Firm Admin",), "Dashboard"), + ReportCard("Users & Roles Report", "Review user access, roles and invite/admin readiness for the firm.", "/firm-admin/dashboard?tab=users", "system", ("Firm Admin",), "Dashboard"), + ReportCard("Firm Settings Completion", "Open firm profile, branding, SMTP and setup status.", "/firm-admin/dashboard?tab=firm-settings", "system", ("Firm Admin",), "Dashboard"), + ReportCard("Services Setup Readiness", "Review selected services and task-template setup readiness.", "/firm-admin/dashboard?tab=services", "work", ("Firm Admin",), "Dashboard"), + ReportCard("Financial Year Report", "Review active FY, FY backup/lock and year setup readiness.", "/firm-admin/dashboard?tab=financial-years", "system", ("Firm Admin",), "Dashboard"), + ReportCard("Firm Audit Logs", "Open firm administration audit activity.", "/firm-admin/dashboard?tab=audit-logs", "audit", ("Firm Admin",), "Dashboard"), + + # Partner + ReportCard("Branch Work Report", "Review branch work status, due work and service-wise operational load.", "/partner/dashboard?tab=branch-work", "work", ("Partner",), "Dashboard"), + ReportCard("Partner Client Report", "Review branch clients, service mapping, pending work and status.", "/partner/dashboard?tab=clients", "client", ("Partner",), "Dashboard"), + ReportCard("Partner Staff Workload", "Review staff workload, open tasks, overdue and review pending work.", "/partner/dashboard?tab=staff", "work", ("Partner",), "Dashboard"), + ReportCard("Partner Review Pending", "Open partner-level review queue and pending approvals.", "/partner/dashboard?tab=review", "work", ("Partner",), "Dashboard"), + ReportCard("Branch Billing Report", "Review branch billing, unbilled work and collection indicators.", "/partner/dashboard?tab=billing", "billing", ("Partner",), "Dashboard"), + + # Manager + ReportCard("Team Work Report", "Review team work, due tasks, workload and current execution status.", "/manager/dashboard?tab=team-work", "work", ("Manager", "Branch Manager"), "Dashboard"), + ReportCard("Manager Due Calendar", "Review due calendar, upcoming work and ageing buckets.", "/manager/dashboard?tab=due-calendar", "work", ("Manager", "Branch Manager"), "Dashboard"), + ReportCard("Manager Review Queue", "Open tasks waiting for manager review or correction.", "/manager/dashboard?tab=review-queue", "work", ("Manager", "Branch Manager"), "Dashboard"), + ReportCard("Client Pending Under Manager", "Review tasks blocked because client data or documents are pending.", "/manager/dashboard?tab=client-pending", "client", ("Manager", "Branch Manager"), "Dashboard"), + ReportCard("Document Pending Report", "Review document requirements, uploads and pending work papers.", "/manager/dashboard?tab=documents", "work", ("Manager", "Branch Manager"), "Dashboard"), + ReportCard("Escalation Register", "Review overdue, ageing and escalation items under manager control.", "/manager/dashboard?tab=escalations", "audit", ("Manager", "Branch Manager"), "Dashboard"), + + # Employee / Staff + ReportCard("My Pending Tasks", "Open my work board and pending assigned tasks.", "/employee/dashboard?tab=work", "my", ("Staff", "Employee"), "Portal"), + ReportCard("My Due Today", "Open work due today from the employee portal.", "/employee/dashboard?tab=work", "my", ("Staff", "Employee"), "Portal"), + ReportCard("My Overdue Tasks", "Open overdue and returned work from the employee portal.", "/employee/dashboard?tab=work", "my", ("Staff", "Employee"), "Portal"), + ReportCard("My Attendance Report", "Open attendance punch details and attendance history.", "/employee/dashboard?tab=attendance", "hr", ("Staff", "Employee"), "Portal"), + ReportCard("My Leave Report", "Open leave, balances and leave activity from HR profile.", "/employee/dashboard?tab=hr", "hr", ("Staff", "Employee"), "Portal"), + ReportCard("My Documents Report", "Open my documents and uploaded work papers.", "/employee/dashboard?tab=work", "my", ("Staff", "Employee"), "Portal"), + + # Client + ReportCard("My Compliance Status", "Review service-wise compliance and current work status.", "/client/dashboard?tab=services-documents", "client", ("Client",), "Portal"), + ReportCard("Pending From Me", "Open documents, data and replies pending from the client side.", "/client/dashboard?tab=action-centre", "client", ("Client",), "Portal"), + ReportCard("Document History", "Open uploaded documents, firm shared documents and acknowledgements.", "/client/dashboard?tab=services-documents", "client", ("Client",), "Portal"), + ReportCard("Invoice Outstanding", "Open invoices, outstanding amounts and payment status.", "/client/dashboard?tab=billing-messages", "billing", ("Client",), "Portal"), + ReportCard("Payment History", "Open payment and receipt information in the client portal.", "/client/dashboard?tab=billing-messages", "billing", ("Client",), "Portal"), + ReportCard("Messages / Queries", "Open firm messages and query responses.", "/client/dashboard?tab=billing-messages", "client", ("Client",), "Portal"), + + # Consultant + ReportCard("Assigned Work Report", "Review consultant work centre and assigned tasks.", "/consultant/dashboard?tab=work-centre", "my", ("Consultant",), "Portal"), + ReportCard("Consultant Pending Work", "Review pending, due and overdue consultant assignments.", "/consultant/dashboard?tab=work-centre", "my", ("Consultant",), "Portal"), + ReportCard("Consultant Client Report", "Review consultant-linked clients and service requests.", "/consultant/dashboard?tab=clients-requests", "client", ("Consultant",), "Portal"), + ReportCard("Document Pending for Consultant", "Review consultant document and working paper requirements.", "/consultant/dashboard?tab=documents-clarifications", "my", ("Consultant",), "Portal"), + ReportCard("Clarification Pending", "Review clarifications between firm and consultant.", "/consultant/dashboard?tab=documents-clarifications", "my", ("Consultant",), "Portal"), + ReportCard("Consultant Service Requests", "Review consultant service requests and status.", "/consultant/dashboard?tab=clients-requests", "client", ("Consultant",), "Portal"), + + # Existing module shortcuts visible to operational users + ReportCard("Client Master", "Open the existing client master list and filters.", "/clients", "client", ("System Admin", "Firm Admin", "Partner", "Manager"), "Module"), + ReportCard("Work Tracker", "Open the existing work tracker for operational task follow-up.", "/services/work-tracker", "work", ("System Admin", "Firm Admin", "Partner", "Manager"), "Module"), + ReportCard("Subscriptions", "Open client service subscriptions and task generation readiness.", "/services/work-tracker/subscriptions", "work", ("System Admin", "Firm Admin", "Partner", "Manager"), "Module"), + ReportCard("Billing Register", "Open existing billing invoices and payments area.", "/billing", "billing", ("System Admin", "Firm Admin", "Partner"), "Module"), + ReportCard("Billing Payments", "Open existing payment receipts and collections.", "/billing/payments", "billing", ("System Admin", "Firm Admin", "Partner"), "Module"), + ReportCard("Employee Attendance", "Open employee attendance administration page.", "/employees/attendance", "hr", ("System Admin", "Firm Admin", "Partner", "Manager"), "Module"), + ReportCard("Employee Progress", "Open employee progress and performance page.", "/employees/progress", "hr", ("System Admin", "Firm Admin", "Partner", "Manager"), "Module"), + ReportCard("Alerts", "Open alerts and notification centre.", "/alerts", "audit", ("System Admin", "Firm Admin", "Partner", "Manager", "Staff", "Employee", "Client", "Consultant"), "Module"), + ReportCard("Audit Logs", "Open existing audit log page.", "/system-settings/audit-logs", "audit", ("System Admin", "Firm Admin"), "Module"), + ] + + +def _visible(card: ReportCard, roles: set[str]) -> bool: + if _has_any(roles, SYSTEM_ADMIN): + return True + return bool(roles.intersection(set(card.roles))) + + +def get_visible_report_cards(roles: Iterable[str] | None, permissions: Iterable[str] | None = None) -> list[ReportCard]: + roles_set = _role_set(roles) + cards = [card for card in _all_cards() if _visible(card, roles_set)] + + # Permission fallback for older accounts where explicit role names may be absent. + permission_set = set(permissions or []) + if not cards: + if "clients.view" in permission_set: + cards.append(ReportCard("Client Master", "Open the existing client master list and filters.", "/clients", "client", tuple(), "Module")) + if "services.view" in permission_set: + cards.append(ReportCard("Work Tracker", "Open the existing work tracker for operational task follow-up.", "/services/work-tracker", "work", tuple(), "Module")) + if "billing.view" in permission_set: + cards.append(ReportCard("Billing Register", "Open existing billing invoices and payments area.", "/billing", "billing", tuple(), "Module")) + if "employees.ess.view" in permission_set or "employees.work.view_self" in permission_set: + cards.append(ReportCard("My Employee Portal", "Open your employee dashboard, attendance and work board.", "/employee/dashboard", "my", tuple(), "Portal")) + return cards + + +def cards_by_group(cards: Iterable[ReportCard]) -> dict[str, list[ReportCard]]: + groups = {"my": [], "work": [], "client": [], "billing": [], "hr": [], "system": [], "audit": []} + for card in cards: + groups.setdefault(card.group, []).append(card) + return groups + + +def get_report_summary(cards: Iterable[ReportCard]) -> dict[str, int]: + groups = cards_by_group(cards) + return { + "total": sum(len(v) for v in groups.values()), + "my": len(groups.get("my", [])), + "work": len(groups.get("work", [])), + "client": len(groups.get("client", [])), + "billing": len(groups.get("billing", [])), + "hr": len(groups.get("hr", [])), + "system": len(groups.get("system", [])), + "audit": len(groups.get("audit", [])), + } + + +def get_report_tabs(cards: Iterable[ReportCard]) -> list[dict[str, str | int]]: + summary = get_report_summary(cards) + return [ + {"key": "my-reports", "label": "My Reports", "count": summary["my"]}, + {"key": "work", "label": "Work Reports", "count": summary["work"]}, + {"key": "client", "label": "Client Reports", "count": summary["client"]}, + {"key": "billing", "label": "Billing Reports", "count": summary["billing"]}, + {"key": "hr", "label": "HR / Attendance", "count": summary["hr"]}, + {"key": "system", "label": "System Reports", "count": summary["system"]}, + {"key": "audit", "label": "Audit / Alerts", "count": summary["audit"]}, + ] diff --git a/app/modules/unified_reports/templates/unified_reports/dashboard.html b/app/modules/unified_reports/templates/unified_reports/dashboard.html new file mode 100644 index 0000000..102f72b --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/dashboard.html @@ -0,0 +1,100 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

Role-wise Reports Centre

+

Unified Reports Centre

+

Open the reports and existing report modules relevant to your role and current workspace.

+
+
+
+
{{ report_summary.total }}
+
Available
+
+
+
{{ report_summary.work }}
+
Work
+
+
+
{{ report_summary.client }}
+
Client
+
+
+
{{ report_summary.billing }}
+
Billing
+
+
+
+ +
+
+ {% for tab in report_tabs %} + + {{ tab.label }} + {{ tab.count }} + + {% endfor %} +
+
+
+ +
+
Loading reports...
+
+
+ + +{% endblock %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/_cards.html b/app/modules/unified_reports/templates/unified_reports/partials/_cards.html new file mode 100644 index 0000000..fb0b068 --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/_cards.html @@ -0,0 +1,29 @@ +
+
+
+

{{ section_kicker }}

+

{{ section_title }}

+

{{ section_description }}

+
+
{{ active_cards|length }} available
+
+ + {% if active_cards %} +
+ {% for card in active_cards %} + +
+
{{ card.title }}
+ {{ card.badge }} +
+

{{ card.description }}

+
Open report →
+
+ {% endfor %} +
+ {% else %} +
+ No reports are assigned for this category under your current role. Use another reports tab or contact Firm Admin/System Admin for access. +
+ {% endif %} +
diff --git a/app/modules/unified_reports/templates/unified_reports/partials/audit.html b/app/modules/unified_reports/templates/unified_reports/partials/audit.html new file mode 100644 index 0000000..9da7bf4 --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/audit.html @@ -0,0 +1,4 @@ +{% set section_kicker = "Audit Trail" %} +{% set section_title = "Audit / Alerts" %} +{% set section_description = "Audit logs, alerts, escalations and action-tracking report links." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/billing.html b/app/modules/unified_reports/templates/unified_reports/partials/billing.html new file mode 100644 index 0000000..902d857 --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/billing.html @@ -0,0 +1,4 @@ +{% set section_kicker = "Billing" %} +{% set section_title = "Billing Reports" %} +{% set section_description = "Invoices, payments, outstanding, branch billing and platform billing readiness reports." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/client.html b/app/modules/unified_reports/templates/unified_reports/partials/client.html new file mode 100644 index 0000000..3806f7c --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/client.html @@ -0,0 +1,4 @@ +{% set section_kicker = "Clients" %} +{% set section_title = "Client Reports" %} +{% set section_description = "Client master, client portal, pending-from-client, documents and compliance status reports." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/hr.html b/app/modules/unified_reports/templates/unified_reports/partials/hr.html new file mode 100644 index 0000000..3075ed6 --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/hr.html @@ -0,0 +1,4 @@ +{% set section_kicker = "HR & Attendance" %} +{% set section_title = "HR / Attendance Reports" %} +{% set section_description = "Employee attendance, leave, progress and personal HR reports." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/my_reports.html b/app/modules/unified_reports/templates/unified_reports/partials/my_reports.html new file mode 100644 index 0000000..29d589c --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/my_reports.html @@ -0,0 +1,4 @@ +{% set section_kicker = "Personal Reports" %} +{% set section_title = "My Reports" %} +{% set section_description = "Personal work, attendance, client or consultant portal reports available to your login." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/system.html b/app/modules/unified_reports/templates/unified_reports/partials/system.html new file mode 100644 index 0000000..7eb569e --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/system.html @@ -0,0 +1,4 @@ +{% set section_kicker = "Administration" %} +{% set section_title = "System Reports" %} +{% set section_description = "Firm setup, branch setup, users, roles, SMTP, storage, FY and platform readiness reports." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/templates/unified_reports/partials/work.html b/app/modules/unified_reports/templates/unified_reports/partials/work.html new file mode 100644 index 0000000..02914d1 --- /dev/null +++ b/app/modules/unified_reports/templates/unified_reports/partials/work.html @@ -0,0 +1,4 @@ +{% set section_kicker = "Execution" %} +{% set section_title = "Work Reports" %} +{% set section_description = "Task, due-date, review, workload, service readiness and work tracker reports." %} +{% include "modules/unified_reports/templates/unified_reports/partials/_cards.html" %} diff --git a/app/modules/unified_reports/ui.py b/app/modules/unified_reports/ui.py new file mode 100644 index 0000000..a72d42b --- /dev/null +++ b/app/modules/unified_reports/ui.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from app.core.db.common import CommonSessionLocal +from app.core.http_responses import ui_access_denied +from app.core.security.csrf import get_or_create_csrf_token +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.unified_reports.service import ( + can_access_reports, + cards_by_group, + get_report_summary, + get_report_tabs, + get_visible_report_cards, +) + +router = APIRouter(prefix="/reports", tags=["unified-reports-ui"]) + +_ALLOWED_TABS = { + "my-reports": "my", + "work": "work", + "client": "client", + "billing": "billing", + "hr": "hr", + "system": "system", + "audit": "audit", +} + + +def _login_redirect() -> RedirectResponse: + return RedirectResponse(url="/login", status_code=303) + + +def _current_user_or_response(request: Request, db): + current_user = get_current_user(request, db=db) + if not current_user: + return None, _login_redirect() + roles = get_user_roles(db, current_user.id) + permissions = get_user_permissions(db, current_user.id) + if not can_access_reports(roles, permissions): + return None, ui_access_denied() + return current_user, None + + +def _payload(db, current_user): + roles = get_user_roles(db, current_user.id) + permissions = get_user_permissions(db, current_user.id) + cards = get_visible_report_cards(roles, permissions) + groups = cards_by_group(cards) + return { + "current_user_roles": roles, + "current_user_permissions": permissions, + "report_cards": cards, + "report_groups": groups, + "report_tabs": get_report_tabs(cards), + "report_summary": get_report_summary(cards), + } + + +def _base_context(request: Request, db, current_user, **extra): + ctx = { + "request": request, + "current_user": current_user, + "csrf_token": get_or_create_csrf_token(request), + } + ctx.update(_payload(db, current_user)) + ctx.update(extra) + return ctx + + +@router.get("") +def reports_home(request: Request, tab: str = "my-reports"): + db = CommonSessionLocal() + try: + current_user, response = _current_user_or_response(request, db) + if response: + return response + active_tab = tab if tab in _ALLOWED_TABS else "my-reports" + return templates.TemplateResponse( + "modules/unified_reports/templates/unified_reports/dashboard.html", + _base_context(request, db, current_user, title="Reports Centre", active_tab=active_tab), + ) + finally: + db.close() + + +@router.get("/tab/{tab_name}") +def reports_tab(request: Request, tab_name: str): + db = CommonSessionLocal() + try: + current_user, response = _current_user_or_response(request, db) + if response: + return response + active_tab = tab_name if tab_name in _ALLOWED_TABS else "my-reports" + group_key = _ALLOWED_TABS[active_tab] + return templates.TemplateResponse( + f"modules/unified_reports/templates/unified_reports/partials/{active_tab.replace('-', '_')}.html", + _base_context( + request, + db, + current_user, + title="Reports Centre", + active_tab=active_tab, + active_group=group_key, + active_cards=cards_by_group(get_visible_report_cards(get_user_roles(db, current_user.id), get_user_permissions(db, current_user.id))).get(group_key, []), + ), + ) + finally: + db.close() diff --git a/app/ui/app.py b/app/ui/app.py index abf89d0..795e892 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -3,7 +3,6 @@ from fastapi.staticfiles import StaticFiles from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router -from app.modules.staff_dashboard.ui import router as staff_dashboard_router from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router from app.modules.manager_dashboard.ui import router as manager_dashboard_router from app.modules.managers.ui import router as managers_ui_router @@ -28,6 +27,7 @@ from app.modules.notice_cases.ui import router as notice_cases_router from app.modules.wizards.ui import router as wizards_ui_router from app.modules.system_admin_dashboard.ui import router as system_admin_dashboard_router from app.ui.routes.auth import router as auth_router +from app.modules.unified_reports.ui import router as unified_reports_router from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router @@ -50,12 +50,12 @@ def mount_ui(app: FastAPI) -> None: app.include_router(marketplace_ui_router) app.include_router(documents_ui_router) app.include_router(alerts_ui_router) + app.include_router(unified_reports_router) app.include_router(notice_cases_router) app.include_router(wizards_ui_router) app.include_router(system_admin_dashboard_router) app.include_router(work_detail_ui_router) app.include_router(clients_ui_router) - app.include_router(staff_dashboard_router) app.include_router(employees_ui_router) app.include_router(manager_dashboard_router) app.include_router(managers_ui_router) @@ -69,3 +69,4 @@ def mount_ui(app: FastAPI) -> None: +