diff --git a/app/modules/clients/management_ui.py b/app/modules/clients/management_ui.py new file mode 100644 index 0000000..03afdaa --- /dev/null +++ b/app/modules/clients/management_ui.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +from math import ceil + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +from app.core.db.common import CommonSessionLocal +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.clients.access import ( + build_scope, + can_view_client_row, + enforce_partner_scope, + is_partner_role, +) +from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch +from app.modules.clients.service import list_clients_payload +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.registrations.models import ClientRegistration, RegistrationType + +router = APIRouter(prefix="/clients", tags=["unified-client-management"]) + + +def _has_perm_factory(db, user): + permissions = set(get_user_permissions(db, user.id)) + return lambda code: code in permissions + + +def _role_names(db, user) -> set[str]: + return {str(role).strip().lower() for role in get_user_roles(db, user.id)} + + +def _apply_role_scope(scope, user, role_names: set[str]): + if "system admin" in role_names: + scope.allow_all_clients = True + scope.allow_cross_tenant = True + scope.allow_cross_branch = True + scope.own_only = False + scope.locked_partner_id = None + return enforce_partner_scope(scope, user=user, role_names=role_names) + + +def _viewer_partner_id(user, role_names: set[str]): + return int(user.id) if is_partner_role(role_names) else None + + +def _norm(value) -> str: + return str(value or "").strip().lower() + + +def _matches(needle: str, *values) -> bool: + if not needle: + return True + return any(needle in _norm(value) for value in values) + + +@router.get("/management") +def unified_client_management( + request: Request, + q: str = "", + status: str = "", + client_type: str = "", + registration_type: str = "", + registration_status: str = "", + page: int = 1, + per_page: int = 25, +): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse("/login", 303) + + permissions = set(get_user_permissions(db, user.id)) + if "clients.view" not in permissions: + return RedirectResponse("/dashboard", 303) + + roles = _role_names(db, user) + # This management console is intentionally exposed to the two requested + # operational roles. System Admin retains access for support/admin use. + if not ({"partner", "firm admin", "system admin"} & roles): + return RedirectResponse("/clients", 303) + + has = _has_perm_factory(db, user) + scope = _apply_role_scope(build_scope(request, user, has), user, roles) + + # Reuse the production client-list scope engine rather than inventing a + # second visibility rule. Fetch a broad scoped set, then enrich it. + base = list_clients_payload( + db, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + allow_cross_branch=scope.allow_cross_branch, + allow_all_clients=scope.allow_all_clients, + partner_id=None, + viewer_partner_id=_viewer_partner_id(user, roles), + q="", + status="", + client_type="", + client_group_id=None, + include_archived=True, + page=1, + per_page=10000, + sort_by="client_name", + sort_order="asc", + ) + visible_rows = [ + row for row in base.get("rows", []) + if can_view_client_row(scope, row, user_id=user.id) + ] + visible_ids = [int(row["id"]) for row in visible_rows if row.get("id") is not None] + row_meta = {int(row["id"]): row for row in visible_rows if row.get("id") is not None} + + if not visible_ids: + clients = [] + else: + clients = db.execute( + select(Client) + .where(Client.id.in_(visible_ids)) + .order_by(Client.client_name.asc(), Client.id.asc()) + ).scalars().all() + + businesses = [] + branches = [] + registration_pairs = [] + if visible_ids: + businesses = db.execute( + select(ClientBusinessUnit) + .where(ClientBusinessUnit.client_id.in_(visible_ids)) + .order_by( + ClientBusinessUnit.client_id, + ClientBusinessUnit.is_active.desc(), + ClientBusinessUnit.is_primary.desc(), + ClientBusinessUnit.business_name.asc(), + ) + ).scalars().all() + branches = db.execute( + select(ClientBranch) + .where(ClientBranch.client_id.in_(visible_ids)) + .order_by( + ClientBranch.client_id, + ClientBranch.is_active.desc(), + ClientBranch.is_primary.desc(), + ClientBranch.branch_name.asc(), + ) + ).scalars().all() + registration_pairs = db.execute( + select(ClientRegistration, RegistrationType) + .join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id) + .where(ClientRegistration.client_id.in_(visible_ids)) + .order_by( + ClientRegistration.client_id, + RegistrationType.sort_order.asc(), + RegistrationType.code.asc(), + ClientRegistration.registration_number.asc(), + ) + ).all() + + businesses_by_client: dict[int, list] = {} + businesses_by_id: dict[int, ClientBusinessUnit] = {} + for row in businesses: + businesses_by_client.setdefault(row.client_id, []).append(row) + businesses_by_id[row.id] = row + + branches_by_client: dict[int, list] = {} + branches_by_business: dict[int, list] = {} + branches_by_id: dict[int, ClientBranch] = {} + for row in branches: + branches_by_client.setdefault(row.client_id, []).append(row) + branches_by_business.setdefault(row.business_unit_id, []).append(row) + branches_by_id[row.id] = row + + regs_by_client: dict[int, list[tuple[ClientRegistration, RegistrationType]]] = {} + regs_by_business: dict[int, list[tuple[ClientRegistration, RegistrationType]]] = {} + regs_by_branch: dict[int, list[tuple[ClientRegistration, RegistrationType]]] = {} + for reg, reg_type in registration_pairs: + pair = (reg, reg_type) + regs_by_client.setdefault(reg.client_id, []).append(pair) + if reg.business_unit_id: + regs_by_business.setdefault(reg.business_unit_id, []).append(pair) + if reg.client_branch_id: + regs_by_branch.setdefault(reg.client_branch_id, []).append(pair) + + needle = _norm(q) + reg_type_filter = _norm(registration_type) + reg_status_filter = _norm(registration_status) + client_status_filter = _norm(status) + client_type_filter = _norm(client_type) + + filtered_clients = [] + for client in clients: + if client_status_filter and _norm(client.status) != client_status_filter: + continue + if client_type_filter and _norm(client.client_type) != client_type_filter: + continue + + client_businesses = businesses_by_client.get(client.id, []) + client_branches = branches_by_client.get(client.id, []) + client_regs = regs_by_client.get(client.id, []) + + if reg_type_filter and not any(_norm(rt.code) == reg_type_filter for _, rt in client_regs): + continue + if reg_status_filter and not any(_norm(reg.status) == reg_status_filter for reg, _ in client_regs): + continue + + if needle: + hit = _matches( + needle, + client.client_name, client.trade_name, client.client_code, + client.pan, client.gstin, client.tan, client.cin_llpin, + client.mobile, client.email, client.city, client.state, + ) + if not hit: + hit = any(_matches( + needle, b.business_name, b.trade_name, b.business_code, b.nature_of_business + ) for b in client_businesses) + if not hit: + hit = any(_matches( + needle, br.branch_name, br.branch_code, br.city, br.state, br.pincode + ) for br in client_branches) + if not hit: + hit = any(_matches( + needle, reg.registration_number, reg.legal_name, reg.trade_name, + reg.state, reg.jurisdiction, rt.code, rt.name + ) for reg, rt in client_regs) + if not hit: + continue + filtered_clients.append(client) + + per_page = per_page if per_page in {10, 25, 50, 100} else 25 + total = len(filtered_clients) + pages = max(1, ceil(total / per_page)) + page = max(1, min(int(page or 1), pages)) + start = (page - 1) * per_page + page_clients = filtered_clients[start:start + per_page] + + hierarchy = [] + for client in page_clients: + business_nodes = [] + assigned_reg_ids: set[int] = set() + for business in businesses_by_client.get(client.id, []): + branch_nodes = [] + for branch in branches_by_business.get(business.id, []): + branch_regs = regs_by_branch.get(branch.id, []) + assigned_reg_ids.update(reg.id for reg, _ in branch_regs) + branch_nodes.append({ + "row": branch, + "registrations": branch_regs, + }) + direct_business_regs = [ + pair for pair in regs_by_business.get(business.id, []) + if not pair[0].client_branch_id + ] + assigned_reg_ids.update(reg.id for reg, _ in direct_business_regs) + business_nodes.append({ + "row": business, + "branches": branch_nodes, + "registrations": direct_business_regs, + }) + + unassigned_regs = [ + pair for pair in regs_by_client.get(client.id, []) + if pair[0].id not in assigned_reg_ids + ] + hierarchy.append({ + "client": client, + "meta": row_meta.get(client.id, {}), + "businesses": business_nodes, + "unassigned_registrations": unassigned_regs, + "business_count": len(businesses_by_client.get(client.id, [])), + "branch_count": len(branches_by_client.get(client.id, [])), + "registration_count": len(regs_by_client.get(client.id, [])), + }) + + all_visible_client_ids = {c.id for c in filtered_clients} + filtered_businesses = [b for b in businesses if b.client_id in all_visible_client_ids] + filtered_branches = [b for b in branches if b.client_id in all_visible_client_ids] + filtered_regs = [(r, t) for r, t in registration_pairs if r.client_id in all_visible_client_ids] + + counters = { + "clients": len(filtered_clients), + "businesses": len(filtered_businesses), + "branches": len(filtered_branches), + "registrations": len(filtered_regs), + "gstin": sum(1 for _, rt in filtered_regs if _norm(rt.code) == "gstin"), + "tan": sum(1 for _, rt in filtered_regs if _norm(rt.code) == "tan"), + "active_registrations": sum( + 1 for reg, _ in filtered_regs + if _norm(reg.status) in {"active", "valid", "registered"} + ), + } + + registration_types = db.execute( + select(RegistrationType) + .where(RegistrationType.is_active.is_(True)) + .order_by(RegistrationType.sort_order.asc(), RegistrationType.name.asc()) + ).scalars().all() + + client_types = sorted({str(c.client_type) for c in clients if c.client_type}) + registration_statuses = sorted({ + str(reg.status) for reg, _ in registration_pairs if reg.status + }) + + return templates.TemplateResponse( + "modules/clients/templates/clients/management.html", + { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": list(permissions), + "title": "Client Management", + "hierarchy": hierarchy, + "counters": counters, + "q": q, + "status": status, + "client_type": client_type, + "registration_type": registration_type, + "registration_status": registration_status, + "registration_types": registration_types, + "registration_statuses": registration_statuses, + "client_types": client_types, + "page": page, + "pages": pages, + "per_page": per_page, + "total": total, + "can_create": "clients.create" in permissions, + "can_edit": "clients.edit" in permissions, + }, + ) + finally: + db.close() diff --git a/app/modules/clients/templates/clients/management.html b/app/modules/clients/templates/clients/management.html new file mode 100644 index 0000000..d0e11ed --- /dev/null +++ b/app/modules/clients/templates/clients/management.html @@ -0,0 +1,216 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
Clients · Unified Management
+

Client Management

+

Compact Client → Business → Branch → Registration management using the existing ERP masters and permissions.

+
+
+ {% if can_create %}+ Add Client{% endif %} + Existing Client List +
+
+ +
+ {% for label, value in [ + ('Clients', counters.clients), ('Businesses', counters.businesses), ('Branches', counters.branches), + ('Registrations', counters.registrations), ('GSTINs', counters.gstin), ('TANs', counters.tan) + ] %} +
+
{{ label }}
+
{{ value }}
+
+ {% endfor %} +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
Search covers client name/code, business/trade name, PAN, GSTIN, TAN, CIN/LLPIN, branch, mobile and email.
+
+ Clear + +
+
+
+ +
+
+
+
{{ total }} client{{ '' if total == 1 else 's' }}
+
Expand only the client you need. Existing detail/edit/business-structure pages remain available.
+
+
Page {{ page }} of {{ pages }}
+
+ + {% for node in hierarchy %} + {% set client = node.client %} +
+ +
+
+ › +
+
{{ client.client_name }}
+
+ {{ client.client_code }} + {% if client.trade_name and client.trade_name != client.client_name %}· {{ client.trade_name }}{% endif %} + {% if client.pan %}· PAN {{ client.pan }}{% endif %} +
+
+
+
{{ client.client_type }}
+
{{ node.business_count }}business
+
{{ node.branch_count }}branch
+
+ View + Manage Structure + {% if can_edit %}Edit{% endif %} +
+
+
+ +
+ {% if node.businesses %} +
+ {% for business_node in node.businesses %} + {% set business = business_node.row %} +
+
+
+
+ {{ business.business_name }} + {% if business.is_primary %}Primary Business{% endif %} + {% if not business.is_active %}Inactive{% endif %} +
+
{{ business.business_code }}{% if business.trade_name %} · Trade: {{ business.trade_name }}{% endif %}{% if business.nature_of_business %} · {{ business.nature_of_business }}{% endif %}
+
+ Manage business +
+ + {% for branch_node in business_node.branches %} + {% set branch = branch_node.row %} +
+
+
+ ↳ {{ branch.branch_name }} + {{ branch.branch_code }}{% if branch.city %} · {{ branch.city }}{% endif %}{% if branch.state %}, {{ branch.state }}{% endif %} + {% if branch.is_primary %}Primary Branch{% endif %} +
+
{{ branch_node.registrations|length }} registration{{ '' if branch_node.registrations|length == 1 else 's' }}
+
+ {% if branch_node.registrations %} +
+ {% for reg, rt in branch_node.registrations %} +
+
+
+ {{ rt.code }} + {{ reg.registration_number }} + {{ reg.status|title }} +
+ {% if reg.trade_name or reg.legal_name %}
{{ reg.trade_name or reg.legal_name }}
{% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+ {% endfor %} + + {% if business_node.registrations %} +
+
Business-level registrations
+
+ {% for reg, rt in business_node.registrations %} +
+ {{ rt.code }} + {{ reg.registration_number }} + {{ reg.trade_name or reg.legal_name or '' }} +
+ {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+ No Business/Business Unit has been created for this client. + {% if can_edit %}Create business structure{% endif %} +
+ {% endif %} + + {% if node.unassigned_registrations %} +
+
Client-level / unassigned registrations
+
+ {% for reg, rt in node.unassigned_registrations %} +
+ {{ rt.code }} + {{ reg.registration_number }} + {{ reg.trade_name or reg.legal_name or '' }} +
+ {% endfor %} +
+
+ {% endif %} +
+
+ {% else %} +
+
No clients match the selected filters.
+
Clear one or more filters and try again.
+
+ {% endfor %} +
+ + {% if pages > 1 %} +
+
Showing {{ ((page - 1) * per_page) + 1 if total else 0 }}–{{ [page * per_page, total]|min }} of {{ total }}
+
+ {% set qs = 'q=' ~ (q|urlencode) ~ '&status=' ~ (status|urlencode) ~ '&client_type=' ~ (client_type|urlencode) ~ '®istration_type=' ~ (registration_type|urlencode) ~ '®istration_status=' ~ (registration_status|urlencode) ~ '&per_page=' ~ per_page %} + {% if page > 1 %}Previous{% endif %} + {{ page }} / {{ pages }} + {% if page < pages %}Next{% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/app/ui/app.py b/app/ui/app.py index 20aff43..89f0d8b 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router +from app.modules.clients.management_ui import router as client_management_ui_router from app.modules.clients.scope_ui import router as client_scope_ui_router from app.modules.client_groups.ui import router as client_groups_ui_router from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router @@ -110,6 +111,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(system_admin_dashboard_router) app.include_router(work_detail_ui_router) app.include_router(clients_ui_router) + app.include_router(client_management_ui_router) app.include_router(client_scope_ui_router) app.include_router(client_groups_ui_router) app.include_router(registrations_ui_router) diff --git a/app/ui/templates/components/firm_admin_navigation_v2.html b/app/ui/templates/components/firm_admin_navigation_v2.html index be483c4..3d96a35 100644 --- a/app/ui/templates/components/firm_admin_navigation_v2.html +++ b/app/ui/templates/components/firm_admin_navigation_v2.html @@ -29,6 +29,7 @@ 'active': _firm_admin_path.startswith('/clients'), 'children': [ {'label': 'Client List', 'url': '/clients', 'active': _firm_admin_path == '/clients'}, + {'label': 'Client Management', 'url': '/clients/management', 'active': _firm_admin_path.startswith('/clients/management')}, {'label': 'Add Client', 'url': '/clients/new', 'active': _firm_admin_path.startswith('/clients/new')}, {'label': 'Import Clients', 'url': '/clients/import', 'active': _firm_admin_path.startswith('/clients/import')}, {'label': 'Export Clients', 'url': '/clients/export', 'active': _firm_admin_path.startswith('/clients/export')} diff --git a/app/ui/templates/components/partner_navigation_v2.html b/app/ui/templates/components/partner_navigation_v2.html index 29fe80c..8d05843 100644 --- a/app/ui/templates/components/partner_navigation_v2.html +++ b/app/ui/templates/components/partner_navigation_v2.html @@ -1,4 +1,4 @@ -{% if navigation_render_location_v2|default('page') == 'global-host' or not global_role_navigation_host_v2|default(false) %} +{% if navigation_render_location_v2|default('page') == 'global-host' or not global_role_navigation_host_v2|default(false) %} {# Partner operations navigation. Presentation only: all links use existing production routes and route-level permissions remain authoritative. @@ -62,6 +62,11 @@ 'url': '/partner/clients', 'active': _partner_path.startswith('/partner/clients') }, + { + 'label': 'Client Management', + 'url': '/clients/management', + 'active': _partner_path.startswith('/clients/management') + }, { 'label': 'Add Client', 'url': '/clients/new',