Add firm admin dashboard and workspace switcher
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Firm Admin setup/control dashboard package."""
|
||||
@@ -0,0 +1,374 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.services.models import (
|
||||
FirmServiceSelection,
|
||||
FirmServiceTaskTemplate,
|
||||
ServiceCatalogue,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.modules.billing.models import BillingSettings
|
||||
except Exception: # pragma: no cover - optional module guard
|
||||
BillingSettings = None
|
||||
|
||||
try:
|
||||
from app.modules.email_integration.models import PlatformEmailSettings
|
||||
except Exception: # pragma: no cover - optional W2 guard
|
||||
PlatformEmailSettings = None
|
||||
|
||||
try:
|
||||
from app.modules.core.audit.models import AuditLog
|
||||
except Exception: # pragma: no cover - older schema guard
|
||||
AuditLog = None
|
||||
|
||||
|
||||
FIRM_ADMIN_ROLES = {"Firm Admin", "System Admin"}
|
||||
|
||||
|
||||
def _count(db: Session, stmt) -> int:
|
||||
value = db.execute(stmt).scalar()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _active_tenant_id(request, current_user, roles: set[str]) -> int | None:
|
||||
tenant_id = request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
|
||||
if "System Admin" not in roles:
|
||||
tenant_id = getattr(current_user, "tenant_id", None)
|
||||
return int(tenant_id) if tenant_id else None
|
||||
|
||||
|
||||
def _active_branch_id(request, current_user, roles: set[str]) -> int | None:
|
||||
branch_id = request.session.get("active_branch_id") or getattr(current_user, "branch_id", None)
|
||||
if "System Admin" in roles or "Firm Admin" in roles:
|
||||
if branch_id in (None, "", 0, "0"):
|
||||
return None
|
||||
return int(branch_id)
|
||||
return int(getattr(current_user, "branch_id", 0) or 0) or None
|
||||
|
||||
|
||||
def _tenant(db: Session, tenant_id: int | None) -> Tenant | None:
|
||||
return db.get(Tenant, int(tenant_id)) if tenant_id else None
|
||||
|
||||
|
||||
def _role_ids(db: Session, role_names: set[str]) -> list[int]:
|
||||
return list(db.execute(select(Role.id).where(Role.name.in_(role_names))).scalars().all())
|
||||
|
||||
|
||||
def get_user_role_names(db: Session, user_id: int) -> list[str]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(Role.name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
|
||||
.order_by(Role.name.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def can_access_firm_admin_dashboard(db: Session, current_user) -> bool:
|
||||
roles = set(get_user_role_names(db, current_user.id))
|
||||
return bool(roles.intersection(FIRM_ADMIN_ROLES))
|
||||
|
||||
|
||||
def _branch_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
|
||||
if not tenant_id:
|
||||
return []
|
||||
rows = db.execute(
|
||||
select(Branch)
|
||||
.where(Branch.tenant_id == tenant_id)
|
||||
.order_by(Branch.is_head_office.desc(), Branch.name.asc())
|
||||
).scalars().all()
|
||||
out: list[dict[str, Any]] = []
|
||||
for branch in rows:
|
||||
partner_count = _count(
|
||||
db,
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
User.tenant_id == tenant_id,
|
||||
User.branch_id == branch.id,
|
||||
User.is_active.is_(True),
|
||||
Role.name == "Partner",
|
||||
),
|
||||
)
|
||||
user_count = _count(
|
||||
db,
|
||||
select(func.count(User.id)).where(
|
||||
User.tenant_id == tenant_id,
|
||||
User.branch_id == branch.id,
|
||||
User.is_active.is_(True),
|
||||
),
|
||||
)
|
||||
client_count = _count(
|
||||
db,
|
||||
select(func.count(Client.id)).where(
|
||||
Client.tenant_id == tenant_id,
|
||||
Client.branch_id == branch.id,
|
||||
Client.is_active.is_(True),
|
||||
),
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": branch.id,
|
||||
"code": branch.code,
|
||||
"name": branch.name,
|
||||
"is_active": branch.is_active,
|
||||
"is_head_office": branch.is_head_office,
|
||||
"allow_login": branch.allow_login,
|
||||
"allow_new_assignments": branch.allow_new_assignments,
|
||||
"timezone": branch.timezone,
|
||||
"partner_count": partner_count,
|
||||
"user_count": user_count,
|
||||
"client_count": client_count,
|
||||
"local_storage_path": branch.local_storage_path,
|
||||
"smtp_configured": bool(branch.smtp_host and branch.smtp_port and branch.smtp_username),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
|
||||
if not tenant_id:
|
||||
return []
|
||||
users = db.execute(
|
||||
select(User)
|
||||
.where(User.tenant_id == tenant_id)
|
||||
.order_by(User.is_active.desc(), User.full_name.asc(), User.email.asc())
|
||||
.limit(100)
|
||||
).scalars().all()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for user in users:
|
||||
rows.append(
|
||||
{
|
||||
"id": user.id,
|
||||
"name": user.full_name or user.email,
|
||||
"email": user.email,
|
||||
"designation": user.designation,
|
||||
"mobile": user.mobile,
|
||||
"branch_id": user.branch_id,
|
||||
"roles": get_user_role_names(db, user.id),
|
||||
"is_active": user.is_active,
|
||||
"allow_login": user.allow_login,
|
||||
"is_locked": user.is_locked,
|
||||
"must_change_password": user.must_change_password,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _service_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
|
||||
if not tenant_id:
|
||||
return []
|
||||
selections = db.execute(
|
||||
select(FirmServiceSelection, ServiceCatalogue)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id)
|
||||
.where(FirmServiceSelection.tenant_id == tenant_id)
|
||||
.order_by(ServiceCatalogue.category.asc(), ServiceCatalogue.service_name.asc())
|
||||
).all()
|
||||
out: list[dict[str, Any]] = []
|
||||
for selection, catalogue in selections:
|
||||
template_count = _count(
|
||||
db,
|
||||
select(func.count(FirmServiceTaskTemplate.id)).where(
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue.id,
|
||||
FirmServiceTaskTemplate.is_active.is_(True),
|
||||
),
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": selection.id,
|
||||
"service_code": catalogue.service_code,
|
||||
"service_name": catalogue.service_name,
|
||||
"category": catalogue.category or getattr(getattr(catalogue, "service_category", None), "name", None) or "-",
|
||||
"recurrence_type": catalogue.recurrence_type or "-",
|
||||
"is_enabled": selection.is_enabled,
|
||||
"default_branch_id": selection.default_branch_id,
|
||||
"template_count": template_count,
|
||||
"ready": bool(selection.is_enabled and template_count > 0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _financial_year_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
|
||||
if not tenant_id:
|
||||
return []
|
||||
years = db.execute(
|
||||
select(FinancialYear)
|
||||
.where(FinancialYear.tenant_id == tenant_id)
|
||||
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
|
||||
).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": fy.id,
|
||||
"year_code": fy.year_code,
|
||||
"assessment_year": fy.assessment_year,
|
||||
"start_date": fy.start_date,
|
||||
"end_date": fy.end_date,
|
||||
"is_current": fy.is_current,
|
||||
"is_locked": fy.is_locked,
|
||||
"locked_at_utc": fy.locked_at_utc,
|
||||
}
|
||||
for fy in years
|
||||
]
|
||||
|
||||
|
||||
def _billing_settings_count(db: Session, tenant_id: int | None) -> int:
|
||||
if not tenant_id or BillingSettings is None:
|
||||
return 0
|
||||
return _count(db, select(func.count(BillingSettings.id)).where(BillingSettings.tenant_id == tenant_id))
|
||||
|
||||
|
||||
def _smtp_status(db: Session, tenant_id: int | None) -> dict[str, Any]:
|
||||
if PlatformEmailSettings is not None:
|
||||
try:
|
||||
row = db.execute(select(PlatformEmailSettings).order_by(PlatformEmailSettings.id.desc())).scalars().first()
|
||||
return {
|
||||
"available": True,
|
||||
"configured": bool(row and row.smtp_host and row.smtp_port and row.from_email),
|
||||
"label": "Configured" if row and row.smtp_host and row.smtp_port and row.from_email else "Pending",
|
||||
"message": getattr(row, "from_email", None) or "Platform SMTP not configured",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {"available": False, "configured": False, "label": "Check settings", "message": "Open email settings to verify SMTP."}
|
||||
|
||||
|
||||
def build_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
|
||||
roles = set(get_user_role_names(db, current_user.id))
|
||||
tenant_id = _active_tenant_id(request, current_user, roles)
|
||||
branch_id = _active_branch_id(request, current_user, roles)
|
||||
tenant = _tenant(db, tenant_id)
|
||||
branches = _branch_rows(db, tenant_id)
|
||||
users = _user_rows(db, tenant_id)
|
||||
services = _service_rows(db, tenant_id)
|
||||
financial_years = _financial_year_rows(db, tenant_id)
|
||||
billing_settings_count = _billing_settings_count(db, tenant_id)
|
||||
|
||||
firm_admin_roles = _role_ids(db, {"Firm Admin"})
|
||||
firm_admin_count = 0
|
||||
if tenant_id and firm_admin_roles:
|
||||
firm_admin_count = _count(
|
||||
db,
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.where(User.tenant_id == tenant_id, UserRole.role_id.in_(firm_admin_roles), User.is_active.is_(True)),
|
||||
)
|
||||
|
||||
partner_count = _count(
|
||||
db,
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(User.tenant_id == tenant_id, Role.name == "Partner", User.is_active.is_(True)),
|
||||
) if tenant_id else 0
|
||||
|
||||
client_count = _count(db, select(func.count(Client.id)).where(Client.tenant_id == tenant_id, Client.is_active.is_(True))) if tenant_id else 0
|
||||
user_count = _count(db, select(func.count(User.id)).where(User.tenant_id == tenant_id, User.is_active.is_(True))) if tenant_id else 0
|
||||
service_count = len([s for s in services if s["is_enabled"]])
|
||||
services_without_templates = len([s for s in services if s["is_enabled"] and s["template_count"] <= 0])
|
||||
current_fy = next((fy for fy in financial_years if fy["is_current"]), None)
|
||||
|
||||
branding_ready = bool(tenant and (tenant.display_name or tenant.logo_path or tenant.primary_color or tenant.contact_email))
|
||||
setup_checks = [
|
||||
{"key": "branches", "label": "Branches created", "ok": len(branches) > 0, "detail": f"{len(branches)} branch(es)"},
|
||||
{"key": "firm_admin", "label": "Firm Admin user", "ok": firm_admin_count > 0, "detail": f"{firm_admin_count} active"},
|
||||
{"key": "partners", "label": "Partners assigned", "ok": partner_count > 0, "detail": f"{partner_count} active"},
|
||||
{"key": "services", "label": "Services selected", "ok": service_count > 0, "detail": f"{service_count} enabled"},
|
||||
{"key": "templates", "label": "Task templates ready", "ok": services_without_templates == 0 and service_count > 0, "detail": f"{services_without_templates} service(s) pending"},
|
||||
{"key": "fy", "label": "Current financial year", "ok": bool(current_fy), "detail": current_fy["year_code"] if current_fy else "Not set"},
|
||||
{"key": "branding", "label": "Branding/contact", "ok": branding_ready, "detail": "Started" if branding_ready else "Pending"},
|
||||
{"key": "billing", "label": "Billing settings", "ok": billing_settings_count > 0, "detail": f"{billing_settings_count} setup row(s)"},
|
||||
]
|
||||
setup_score = sum(1 for item in setup_checks if item["ok"])
|
||||
|
||||
overview = {
|
||||
"tenant": tenant,
|
||||
"tenant_id": tenant_id,
|
||||
"branch_id": branch_id,
|
||||
"branch_count": len(branches),
|
||||
"active_branch_count": len([b for b in branches if b["is_active"]]),
|
||||
"user_count": user_count,
|
||||
"firm_admin_count": firm_admin_count,
|
||||
"partner_count": partner_count,
|
||||
"client_count": client_count,
|
||||
"enabled_service_count": service_count,
|
||||
"services_without_templates": services_without_templates,
|
||||
"fy_count": len(financial_years),
|
||||
"current_fy": current_fy,
|
||||
"billing_settings_count": billing_settings_count,
|
||||
"branding_ready": branding_ready,
|
||||
"setup_score": setup_score,
|
||||
"setup_total": len(setup_checks),
|
||||
"setup_percent": round((setup_score / len(setup_checks)) * 100) if setup_checks else 0,
|
||||
"smtp": _smtp_status(db, tenant_id),
|
||||
"today": date.today(),
|
||||
}
|
||||
|
||||
return {
|
||||
"roles": sorted(roles),
|
||||
"tenant": tenant,
|
||||
"overview": overview,
|
||||
"setup_checks": setup_checks,
|
||||
"branches": branches,
|
||||
"users": users,
|
||||
"services": services,
|
||||
"financial_years": financial_years,
|
||||
"reports": _report_cards(),
|
||||
"wizards": _wizard_cards(),
|
||||
"audit_logs": _audit_rows(db, tenant_id),
|
||||
}
|
||||
|
||||
|
||||
def _report_cards() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"group": "Setup Reports", "title": "Firm Setup Completeness", "desc": "Branches, users, roles, services, FY, branding and billing readiness.", "href": "/firm-admin/dashboard?tab=overview"},
|
||||
{"group": "Branch Reports", "title": "Branch Readiness", "desc": "Active branches, partner assignment, users and local storage readiness.", "href": "/firm-admin/dashboard?tab=branches"},
|
||||
{"group": "User Reports", "title": "Users & Roles", "desc": "Firm Admin, Partner, Manager, Staff and login readiness.", "href": "/firm-admin/dashboard?tab=users"},
|
||||
{"group": "Service Reports", "title": "Service Setup Readiness", "desc": "Enabled services and missing task templates.", "href": "/firm-admin/dashboard?tab=services"},
|
||||
{"group": "FY Reports", "title": "Financial Year Status", "desc": "Current, locked and historical financial years.", "href": "/firm-admin/dashboard?tab=financial-years"},
|
||||
]
|
||||
|
||||
|
||||
def _wizard_cards() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"title": "Add / Manage Branches", "desc": "Create branch, office timing, login control and local storage path.", "href": "/system-settings/branches"},
|
||||
{"title": "Invite Users", "desc": "Create Firm Admin, Partner, Manager, Staff and role mapping.", "href": "/system-settings/users"},
|
||||
{"title": "Firm Profile & Branding", "desc": "Display name, logo, colours and contact details.", "href": "/system-settings/branding"},
|
||||
{"title": "Select Firm Services", "desc": "Enable services from system catalogue for this firm.", "href": "/services"},
|
||||
{"title": "Firm Task Templates", "desc": "Customize task list and document requirements for enabled services.", "href": "/services/templates"},
|
||||
{"title": "Financial Years", "desc": "Create, switch, lock and manage financial years.", "href": "/system-settings/financial-years"},
|
||||
{"title": "Billing Settings", "desc": "Invoice prefix, GST settings, payment gateway and bank details.", "href": "/billing/settings"},
|
||||
]
|
||||
|
||||
|
||||
def _audit_rows(db: Session, tenant_id: int | None) -> list[Any]:
|
||||
if AuditLog is None or not tenant_id:
|
||||
return []
|
||||
try:
|
||||
return list(
|
||||
db.execute(
|
||||
select(AuditLog)
|
||||
.where(getattr(AuditLog, "target_tenant_id", tenant_id) == tenant_id)
|
||||
.order_by(AuditLog.created_at_utc.desc())
|
||||
.limit(25)
|
||||
).scalars().all()
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
return list(db.execute(select(AuditLog).order_by(AuditLog.created_at_utc.desc()).limit(25)).scalars().all())
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,74 @@
|
||||
{% 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">Firm Setup Control Centre</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">Firm Administration Dashboard</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Configure firm details, branches, users, roles, services, task templates, financial years, branding and billing readiness.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/system-settings/branches" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">+ Add Branch</a>
|
||||
<a href="/system-settings/users" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Users & Roles</a>
|
||||
{% if 'Partner' in current_user_roles %}<a href="/partner/dashboard" class="rounded-xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-100">Partner Operations</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tabs = [
|
||||
('overview','Overview'),
|
||||
('branches','Branches'),
|
||||
('users','Users & Roles'),
|
||||
('firm-settings','Firm Settings'),
|
||||
('services','Services Setup'),
|
||||
('financial-years','Financial Years'),
|
||||
('reports','Reports'),
|
||||
('wizards','Wizards'),
|
||||
('audit-logs','Audit Logs')
|
||||
] %}
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<div class="flex gap-2 overflow-x-auto" id="firm-admin-tabs">
|
||||
{% for code, label in tabs %}
|
||||
<button type="button" data-tab="{{ code }}" class="firm-admin-tab whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-semibold transition {% if active_tab == code %}bg-brand-600 text-white{% else %}text-slate-600 hover:bg-slate-100{% endif %}">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firm-admin-panel" class="min-h-[24rem]">
|
||||
{% include "modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/" ~ active_tab ~ ".html" ignore missing %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const panel = document.getElementById('firm-admin-panel');
|
||||
const buttons = Array.from(document.querySelectorAll('.firm-admin-tab'));
|
||||
if (!panel || !buttons.length) return;
|
||||
function activate(tab){
|
||||
buttons.forEach(btn => {
|
||||
const on = btn.dataset.tab === tab;
|
||||
btn.classList.toggle('bg-brand-600', on);
|
||||
btn.classList.toggle('text-white', on);
|
||||
btn.classList.toggle('text-slate-600', !on);
|
||||
btn.classList.toggle('hover:bg-slate-100', !on);
|
||||
});
|
||||
}
|
||||
async function loadTab(tab){
|
||||
activate(tab);
|
||||
panel.innerHTML = '<div class="rounded-3xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">Loading...</div>';
|
||||
try {
|
||||
const res = await fetch('/firm-admin/dashboard/tab/' + encodeURIComponent(tab), {headers: {'X-Requested-With':'fetch'}});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
panel.innerHTML = await res.text();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tab);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch (err) {
|
||||
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-6 text-sm font-semibold text-red-800 shadow-soft">Unable to load tab. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
buttons.forEach(btn => btn.addEventListener('click', () => loadTab(btn.dataset.tab)));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<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">Recent Audit Logs</h2><p class="text-sm text-slate-500">Latest firm-level activity, if audit log model is available.</p></div>
|
||||
<a href="/system-settings/audit-logs" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Audit Logs</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">Time</th><th class="px-5 py-3">User</th><th class="px-5 py-3">Action</th><th class="px-5 py-3">Entity</th><th class="px-5 py-3">Status</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for log in audit_logs %}
|
||||
<tr class="hover:bg-slate-50"><td class="px-5 py-3">{{ 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.status or '-' }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No audit logs found or audit model is not available.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<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">Branches</h2><p class="text-sm text-slate-500">Create branches and assign partner/users for each branch.</p></div>
|
||||
<a href="/system-settings/branches" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Manage Branches</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">Branch</th><th class="px-5 py-3">Status</th><th class="px-5 py-3 text-right">Partners</th><th class="px-5 py-3 text-right">Users</th><th class="px-5 py-3 text-right">Clients</th><th class="px-5 py-3">Login / Assignment</th><th class="px-5 py-3">Storage / SMTP</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for branch in branches %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-5 py-3"><div class="font-semibold text-slate-900">{{ branch.name }}</div><div class="text-xs text-slate-500">{{ branch.code }}{% if branch.is_head_office %} > Head Office{% endif %}</div></td>
|
||||
<td class="px-5 py-3"><span class="rounded-full px-2.5 py-1 text-xs font-semibold {% if branch.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-600{% endif %}">{% if branch.is_active %}Active{% else %}Inactive{% endif %}</span></td>
|
||||
<td class="px-5 py-3 text-right font-semibold {% if branch.partner_count %}text-slate-900{% else %}text-amber-700{% endif %}">{{ branch.partner_count }}</td>
|
||||
<td class="px-5 py-3 text-right font-semibold">{{ branch.user_count }}</td>
|
||||
<td class="px-5 py-3 text-right font-semibold">{{ branch.client_count }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-600">Login: {{ 'Allowed' if branch.allow_login else 'Blocked' }}<br>Assignments: {{ 'Allowed' if branch.allow_new_assignments else 'Blocked' }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-600">Storage: {{ branch.local_storage_path or 'Not set' }}<br>SMTP: {{ 'Configured' if branch.smtp_configured else 'Pending' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-5 py-8 text-center text-slate-500">No branches found. Create the first branch from System Settings.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<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">Financial Years</h2><p class="text-sm text-slate-500">Create, mark current, lock and manage financial years from existing FY settings.</p></div>
|
||||
<a href="/system-settings/financial-years" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Manage FY</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">FY</th><th class="px-5 py-3">AY</th><th class="px-5 py-3">Period</th><th class="px-5 py-3">Current</th><th class="px-5 py-3">Lock Status</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for fy in financial_years %}
|
||||
<tr class="hover:bg-slate-50"><td class="px-5 py-3 font-semibold text-slate-900">{{ fy.year_code }}</td><td class="px-5 py-3">{{ fy.assessment_year }}</td><td class="px-5 py-3 text-slate-600">{{ fy.start_date }} to {{ fy.end_date }}</td><td class="px-5 py-3">{% if fy.is_current %}<span class="rounded-full bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700">Current</span>{% else %}<span class="text-slate-400">-</span>{% endif %}</td><td class="px-5 py-3">{% if fy.is_locked %}<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">Locked</span>{% else %}<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-700">Open</span>{% endif %}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No financial years created yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<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">Firm Settings Readiness</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">This tab gives shortcuts to existing firm setup pages. No duplicate settings are created here.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<a href="/system-settings" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200"><div class="font-bold text-slate-900">System Settings</div><p class="mt-1 text-sm text-slate-500">Firm, branch and year configuration dashboard.</p></a>
|
||||
<a href="/system-settings/branding" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200"><div class="font-bold text-slate-900">Branding Settings</div><p class="mt-1 text-sm text-slate-500">Logo, favicon, colours, contact and display name.</p></a>
|
||||
<a href="/email/settings" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200"><div class="font-bold text-slate-900">Email Settings</div><p class="mt-1 text-sm text-slate-500">Branch/firm SMTP and email sender settings.</p></a>
|
||||
<a href="/billing/settings" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200"><div class="font-bold text-slate-900">Billing Settings</div><p class="mt-1 text-sm text-slate-500">Invoice series, GST, bank and payment settings.</p></a>
|
||||
<a href="/documents/storage-nodes" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200"><div class="font-bold text-slate-900">Local Storage Agent</div><p class="mt-1 text-sm text-slate-500">Branch storage node readiness.</p></a>
|
||||
<a href="/system-settings/audit-logs" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-200"><div class="font-bold text-slate-900">Audit Logs</div><p class="mt-1 text-sm text-slate-500">Firm setup and security audit trail.</p></a>
|
||||
</div>
|
||||
</div>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<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 text-slate-500">Setup Score</div><div class="mt-2 text-3xl font-bold {% if overview.setup_percent >= 80 %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ overview.setup_score }}/{{ overview.setup_total }}</div><div class="text-xs text-slate-500">{{ overview.setup_percent }}% firm setup readiness</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">Branches</div><div class="mt-2 text-3xl font-bold text-slate-900">{{ overview.branch_count }}</div><div class="text-xs text-slate-500">{{ overview.active_branch_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">Users</div><div class="mt-2 text-3xl font-bold text-slate-900">{{ overview.user_count }}</div><div class="text-xs text-slate-500">{{ overview.firm_admin_count }} firm admin / {{ overview.partner_count }} partner</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 Enabled</div><div class="mt-2 text-3xl font-bold text-slate-900">{{ overview.enabled_service_count }}</div><div class="text-xs {% if overview.services_without_templates %}text-amber-700{% else %}text-slate-500{% endif %}">{{ overview.services_without_templates }} without task templates</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 text-slate-500">Clients</div><div class="mt-2 text-2xl font-bold">{{ overview.client_count }}</div><div class="text-xs text-slate-500">Active client master</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">Current FY</div><div class="mt-2 text-2xl font-bold {% if overview.current_fy %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ overview.current_fy.year_code if overview.current_fy else 'Not Set' }}</div><div class="text-xs text-slate-500">{{ overview.fy_count }} financial year(s)</div></div>
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Branding</div><div class="mt-2 text-2xl font-bold {% if overview.branding_ready %}text-emerald-700{% else %}text-amber-700{% endif %}">{% if overview.branding_ready %}Started{% else %}Pending{% endif %}</div><div class="text-xs text-slate-500">Logo/contact/colours</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">Billing Settings</div><div class="mt-2 text-2xl font-bold {% if overview.billing_settings_count %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ overview.billing_settings_count }}</div><div class="text-xs text-slate-500">Configured rows</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-bold text-slate-900">Setup Checklist</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Firm Admin should complete these items before branch operations start.</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{% for item in setup_checks %}
|
||||
<a href="/firm-admin/dashboard?tab={% if item.key == 'branches' or item.key == 'partners' %}branches{% elif item.key == 'firm_admin' %}users{% elif item.key == 'services' or item.key == 'templates' %}services{% elif item.key == 'fy' %}financial-years{% else %}firm-settings{% endif %}" class="rounded-2xl border p-4 {% if item.ok %}border-emerald-100 bg-emerald-50{% else %}border-amber-100 bg-amber-50{% endif %}">
|
||||
<div class="flex items-center justify-between gap-3"><span class="font-semibold {% if item.ok %}text-emerald-900{% else %}text-amber-900{% endif %}">{{ item.label }}</span><span class="rounded-full bg-white px-2 py-1 text-xs font-bold {% if item.ok %}text-emerald-700{% else %}text-amber-700{% endif %}">{% if item.ok %}Done{% else %}Pending{% endif %}</span></div>
|
||||
<div class="mt-1 text-xs {% if item.ok %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ item.detail }}</div>
|
||||
</a>
|
||||
{% 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="/system-settings/branches" class="block rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-700">+ Add / Manage Branches</a>
|
||||
<a href="/system-settings/users" class="block rounded-2xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Invite Users & Assign Roles</a>
|
||||
<a href="/system-settings/branding" class="block rounded-2xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Firm Branding</a>
|
||||
<a href="/services" class="block rounded-2xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Select Services</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="space-y-6">
|
||||
{% set groups = reports|groupby('group') %}
|
||||
{% for group, items in groups %}
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-bold text-slate-900">{{ group }}</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for report in items %}
|
||||
<a href="{{ report.href }}" class="rounded-2xl border border-slate-200 p-4 hover:border-brand-200 hover:bg-brand-50/40"><div class="font-semibold text-slate-900">{{ report.title }}</div><p class="mt-1 text-sm text-slate-500">{{ report.desc }}</p><div class="mt-3 text-xs font-semibold text-brand-700">View report</div></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Enabled Services</div><div class="mt-2 text-2xl font-bold">{{ overview.enabled_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">Missing Templates</div><div class="mt-2 text-2xl font-bold {% if overview.services_without_templates %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ overview.services_without_templates }}</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">Action</div><div class="mt-3"><a href="/services" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Firm Services</a></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">Services Setup</h2><p class="text-sm text-slate-500">Enabled services and firm task template readiness.</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">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">Task Templates</th><th class="px-5 py-3">Status</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for service in services %}
|
||||
<tr class="hover:bg-slate-50"><td class="px-5 py-3"><div class="font-semibold text-slate-900">{{ service.service_name }}</div><div class="text-xs text-slate-500">{{ service.service_code }}</div></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.template_count }}</td><td class="px-5 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if service.ready %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{% if service.ready %}Ready{% else %}Template Pending{% endif %}</span></td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No firm services selected yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<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">Users & Roles</h2><p class="text-sm text-slate-500">Firm Admin controls user invitations, branch mapping and role mapping.</p></div>
|
||||
<a href="/system-settings/users" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Manage Users</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">User</th><th class="px-5 py-3">Roles</th><th class="px-5 py-3">Login Status</th><th class="px-5 py-3">Designation</th><th class="px-5 py-3">Mobile</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for user in users %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-5 py-3"><div class="font-semibold text-slate-900">{{ user.name }}</div><div class="text-xs text-slate-500">{{ user.email }}</div></td>
|
||||
<td class="px-5 py-3"><div class="flex flex-wrap gap-1">{% for role in user.roles %}<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ role }}</span>{% else %}<span class="text-xs text-amber-700">No role</span>{% endfor %}</div></td>
|
||||
<td class="px-5 py-3 text-xs"><span class="rounded-full px-2 py-1 font-semibold {% if user.is_active and user.allow_login and not user.is_locked %}bg-emerald-50 text-emerald-700{% else %}bg-red-50 text-red-700{% endif %}">{% if user.is_locked %}Locked{% elif not user.is_active %}Inactive{% elif not user.allow_login %}Login Blocked{% else %}Active{% endif %}</span>{% if user.must_change_password %}<div class="mt-1 font-semibold text-amber-700">Invite/password pending</div>{% endif %}</td>
|
||||
<td class="px-5 py-3 text-slate-600">{{ user.designation or '-' }}</td>
|
||||
<td class="px-5 py-3 text-slate-600">{{ user.mobile or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No users found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-bold text-slate-900">Firm Admin Wizards & Setup Shortcuts</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">These cards link to existing setup screens. They do not duplicate existing features.</p>
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for wizard in wizards %}
|
||||
<a href="{{ wizard.href }}" class="rounded-2xl border border-slate-200 p-4 hover:border-brand-200 hover:bg-brand-50/40"><div class="font-semibold text-slate-900">{{ wizard.title }}</div><p class="mt-1 text-sm text-slate-500">{{ wizard.desc }}</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
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.firm_admin_dashboard.service import build_dashboard_payload, can_access_firm_admin_dashboard
|
||||
|
||||
router = APIRouter(prefix="/firm-admin", tags=["firm-admin-dashboard-ui"])
|
||||
|
||||
VALID_TABS = {
|
||||
"overview": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/overview.html",
|
||||
"branches": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/branches.html",
|
||||
"users": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html",
|
||||
"firm-settings": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/firm_settings.html",
|
||||
"services": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/services.html",
|
||||
"financial-years": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/financial_years.html",
|
||||
"reports": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/reports.html",
|
||||
"wizards": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/wizards.html",
|
||||
"audit-logs": "firm_admin_dashboard/templates/firm_admin_dashboard/partials/audit_logs.html",
|
||||
}
|
||||
|
||||
|
||||
def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"):
|
||||
payload = build_dashboard_payload(db, request, current_user)
|
||||
return {
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"current_user_roles": get_user_roles(db, current_user.id),
|
||||
"current_user_permissions": get_user_permissions(db, current_user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"title": "Firm Administration",
|
||||
"active_tab": active_tab,
|
||||
**payload,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(request: Request, tab: str = "overview"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not can_access_firm_admin_dashboard(db, current_user):
|
||||
return ui_access_denied()
|
||||
active_tab = tab if tab in VALID_TABS else "overview"
|
||||
return templates.TemplateResponse(
|
||||
"modules/firm_admin_dashboard/templates/firm_admin_dashboard/dashboard.html",
|
||||
_ctx(request, db, current_user, active_tab=active_tab),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/dashboard/tab/{tab_name}")
|
||||
def dashboard_tab(request: Request, tab_name: str):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not can_access_firm_admin_dashboard(db, current_user):
|
||||
return ui_access_denied()
|
||||
active_tab = tab_name if tab_name in VALID_TABS else "overview"
|
||||
return templates.TemplateResponse(VALID_TABS[active_tab], _ctx(request, db, current_user, active_tab=active_tab))
|
||||
finally:
|
||||
db.close()
|
||||
@@ -25,6 +25,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.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
||||
|
||||
|
||||
def mount_ui(app: FastAPI) -> None:
|
||||
@@ -32,6 +33,7 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.mount("/storage", StaticFiles(directory="/app/data/storage"), name="storage")
|
||||
app.include_router(marketplace_public_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(firm_admin_dashboard_router)
|
||||
app.include_router(system_settings_router)
|
||||
app.include_router(email_integration_router)
|
||||
app.include_router(domain_management_router)
|
||||
|
||||
+18
-22
@@ -117,35 +117,32 @@ def _post_login_redirect(must_change_password: bool, permissions: set[str], role
|
||||
if must_change_password:
|
||||
return "/change-password-required"
|
||||
|
||||
if "Client" in roles:
|
||||
return "/client/dashboard"
|
||||
|
||||
if "Consultant" in roles:
|
||||
return "/consultant/dashboard"
|
||||
|
||||
role_set = set(roles or [])
|
||||
|
||||
# System Admin must land on platform dashboard before employee/self-service routing.
|
||||
# Platform owner always lands on platform control centre first.
|
||||
if "System Admin" in role_set:
|
||||
return "/system-admin/dashboard"
|
||||
|
||||
# Phase 7K refinement:
|
||||
# Dedicated Partner users should land directly on Partner Workspace.
|
||||
# System/Firm Admin users are not forced here because they keep broader admin context.
|
||||
if "Partner" in role_set and not role_set.intersection({"System Admin", "Firm Admin"}):
|
||||
if "Client" in role_set:
|
||||
return "/client/dashboard"
|
||||
|
||||
if "Consultant" in role_set:
|
||||
return "/consultant/dashboard"
|
||||
|
||||
# If Firm Admin is also Partner, daily operations are more frequent;
|
||||
# the workspace switcher exposes Firm Administration when required.
|
||||
if "Firm Admin" in role_set and "Partner" in role_set:
|
||||
return "/partner/dashboard"
|
||||
|
||||
# Phase 7J refinement:
|
||||
# Dedicated manager users should land directly on Manager Workspace.
|
||||
# Higher management roles are intentionally not redirected here because
|
||||
# they may later get their own Firm Admin dashboards.
|
||||
if role_set.intersection({"Manager", "Branch Manager"}) and not role_set.intersection({"System Admin", "Firm Admin", "Partner"}):
|
||||
if "Firm Admin" in role_set:
|
||||
return "/firm-admin/dashboard"
|
||||
|
||||
if "Partner" in role_set:
|
||||
return "/partner/dashboard"
|
||||
|
||||
if role_set.intersection({"Manager", "Branch Manager"}):
|
||||
return "/manager/dashboard"
|
||||
|
||||
# Phase 7I refinement:
|
||||
# For internal firm users, make My Workspace the default landing page.
|
||||
# This keeps Client/Consultant portal routing unchanged and only falls back
|
||||
# to System Settings where the login has no employee/self-service access.
|
||||
if (
|
||||
"employees.ess.view" in permissions
|
||||
or "employees.work.view_self" in permissions
|
||||
@@ -153,7 +150,7 @@ def _post_login_redirect(must_change_password: bool, permissions: set[str], role
|
||||
or "employees.leave.view_self" in permissions
|
||||
or "employees.documents.view_self" in permissions
|
||||
or "employees.payroll.view_self" in permissions
|
||||
or {"System Admin", "Firm Admin", "Partner", "Staff"}.intersection(role_set)
|
||||
or "Staff" in role_set
|
||||
):
|
||||
return "/employee/dashboard"
|
||||
|
||||
@@ -162,7 +159,6 @@ def _post_login_redirect(must_change_password: bool, permissions: set[str], role
|
||||
|
||||
return "/employee/dashboard"
|
||||
|
||||
|
||||
def _is_user_login_allowed(user: User) -> tuple[bool, str | None]:
|
||||
if not user:
|
||||
return False, "Invalid credentials"
|
||||
|
||||
@@ -406,6 +406,7 @@
|
||||
Your Firm: {{ current_firm_name }}
|
||||
• Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %}
|
||||
</div>
|
||||
{% include "ui/templates/components/workspace_switcher.html" %}
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
{% if "Consultant" in ui_roles %}
|
||||
<a class="inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" href="/consultant/profile">My Profile</a>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% if full_auth %}
|
||||
{% set ws = namespace(count=0) %}
|
||||
{% if 'System Admin' in ui_roles %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if 'Firm Admin' in ui_roles %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if 'Partner' in ui_roles %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if 'Manager' in ui_roles or 'Branch Manager' in ui_roles %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if 'Staff' in ui_roles or can_view_employee_portal(current_user, ui_perms, ui_roles) %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if 'Client' in ui_roles %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if 'Consultant' in ui_roles %}{% set ws.count = ws.count + 1 %}{% endif %}
|
||||
{% if ws.count > 1 %}
|
||||
<div class="mt-2 flex justify-end">
|
||||
<label class="sr-only" for="workspace_switcher">Workspace</label>
|
||||
<select id="workspace_switcher" onchange="if(this.value){window.location.href=this.value;}" class="max-w-xs rounded-lg border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-800 shadow-sm hover:bg-brand-100">
|
||||
<option value="">Workspace: switch view</option>
|
||||
{% if 'System Admin' in ui_roles %}<option value="/system-admin/dashboard" {% if current_path.startswith('/system-admin') %}selected{% endif %}>System Admin - Platform Control</option>{% endif %}
|
||||
{% if 'Partner' in ui_roles %}<option value="/partner/dashboard" {% if current_path.startswith('/partner') %}selected{% endif %}>Partner Operations</option>{% endif %}
|
||||
{% if 'Firm Admin' in ui_roles %}<option value="/firm-admin/dashboard" {% if current_path.startswith('/firm-admin') %}selected{% endif %}>Firm Administration</option>{% endif %}
|
||||
{% if 'Manager' in ui_roles or 'Branch Manager' in ui_roles %}<option value="/manager/dashboard" {% if current_path.startswith('/manager') %}selected{% endif %}>Manager / Team Workspace</option>{% endif %}
|
||||
{% if 'Staff' in ui_roles or can_view_employee_portal(current_user, ui_perms, ui_roles) %}<option value="/employee/dashboard" {% if current_path.startswith('/employee') %}selected{% endif %}>My Staff Workspace</option>{% endif %}
|
||||
{% if 'Client' in ui_roles %}<option value="/client/dashboard" {% if current_path.startswith('/client') %}selected{% endif %}>Client Portal</option>{% endif %}
|
||||
{% if 'Consultant' in ui_roles %}<option value="/consultant/dashboard" {% if current_path.startswith('/consultant') %}selected{% endif %}>Consultant Workspace</option>{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user