Add firm admin dashboard and workspace switcher

This commit is contained in:
A R R R Associates
2026-07-04 16:22:45 +05:30
parent 7e10558186
commit a3b57155cf
17 changed files with 752 additions and 22 deletions
@@ -0,0 +1 @@
"""Firm Admin setup/control dashboard package."""
+374
View File
@@ -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 %}
@@ -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 %} &gt; {{ 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>
@@ -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 %} &gt; 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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+74
View File
@@ -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()