From 2dc2a712e832031894967338882f74bde03d9530 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sat, 25 Jul 2026 22:32:35 +0530 Subject: [PATCH] Add client groups and family tracking --- alembic/env.py | 1 + .../20260725_client_groups_family_tracking.py | 46 ++++ app/modules/client_groups/__init__.py | 0 app/modules/client_groups/models.py | 32 +++ app/modules/client_groups/service.py | 105 +++++++++ .../templates/client_groups/detail.html | 1 + .../templates/client_groups/form.html | 1 + .../templates/client_groups/list.html | 1 + app/modules/client_groups/ui.py | 91 ++++++++ app/modules/clients/filters.py | 5 + app/modules/clients/import_service.py | 28 +++ app/modules/clients/models.py | 3 + app/modules/clients/repository.py | 209 +++++------------- app/modules/clients/schemas.py | 10 +- app/modules/clients/service.py | 32 +++ .../clients/templates/clients/detail.html | 3 +- .../clients/templates/clients/list.html | 2 +- .../templates/clients/partials/form.html | 20 +- .../templates/clients/partials/table.html | 2 +- app/modules/clients/ui.py | 11 + app/ui/app.py | 2 + 21 files changed, 444 insertions(+), 161 deletions(-) create mode 100644 alembic/versions/20260725_client_groups_family_tracking.py create mode 100644 app/modules/client_groups/__init__.py create mode 100644 app/modules/client_groups/models.py create mode 100644 app/modules/client_groups/service.py create mode 100644 app/modules/client_groups/templates/client_groups/detail.html create mode 100644 app/modules/client_groups/templates/client_groups/form.html create mode 100644 app/modules/client_groups/templates/client_groups/list.html create mode 100644 app/modules/client_groups/ui.py diff --git a/alembic/env.py b/alembic/env.py index 5608fe5..dbaa994 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -33,6 +33,7 @@ from app.modules.core.audit import models as audit_models # noqa: F401 # Business module models from app.modules.clients import models as client_models # noqa: F401 +from app.modules.client_groups import models as client_group_models # noqa: F401 from app.modules.clients import association_models as client_association_models # noqa: F401 from app.modules.services import models as service_models # noqa: F401 from app.modules.consultants import models as consultant_models # noqa: F401 diff --git a/alembic/versions/20260725_client_groups_family_tracking.py b/alembic/versions/20260725_client_groups_family_tracking.py new file mode 100644 index 0000000..99c652e --- /dev/null +++ b/alembic/versions/20260725_client_groups_family_tracking.py @@ -0,0 +1,46 @@ +"""Client groups and family tracking. + +Revision ID: 20260725_client_groups_family_tracking +Revises: 20260723_phase6_pan_login_client_identity +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260725_client_groups_family_tracking" +down_revision = "20260723_phase6_pan_login_client_identity" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "client_groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("group_code", sa.String(50), nullable=False), + sa.Column("group_name", sa.String(200), nullable=False), + sa.Column("group_type", sa.String(50), nullable=False, server_default="Family"), + sa.Column("primary_contact_name", sa.String(200)), sa.Column("primary_contact_mobile", sa.String(20)), sa.Column("primary_contact_email", sa.String(255)), + sa.Column("assigned_partner_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), + sa.Column("primary_consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="SET NULL")), + sa.Column("notes", sa.Text()), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "group_code", name="uq_client_groups_tenant_code"), + ) + for c in ("tenant_id","group_code","group_name","group_type","assigned_partner_user_id","primary_consultant_id","is_active"): + op.create_index(f"ix_client_groups_{c}", "client_groups", [c]) + op.add_column("clients", sa.Column("client_group_id", sa.Integer(), nullable=True)) + op.add_column("clients", sa.Column("group_relationship", sa.String(100), nullable=True)) + op.add_column("clients", sa.Column("is_group_head", sa.Boolean(), nullable=False, server_default=sa.false())) + op.create_foreign_key("fk_clients_client_group_id", "clients", "client_groups", ["client_group_id"], ["id"], ondelete="SET NULL") + op.create_index("ix_clients_client_group_id", "clients", ["client_group_id"]) + op.create_index("ix_clients_is_group_head", "clients", ["is_group_head"]) + + +def downgrade(): + op.drop_index("ix_clients_is_group_head", table_name="clients") + op.drop_index("ix_clients_client_group_id", table_name="clients") + op.drop_constraint("fk_clients_client_group_id", "clients", type_="foreignkey") + op.drop_column("clients", "is_group_head"); op.drop_column("clients", "group_relationship"); op.drop_column("clients", "client_group_id") + op.drop_table("client_groups") diff --git a/app/modules/client_groups/__init__.py b/app/modules/client_groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/client_groups/models.py b/app/modules/client_groups/models.py new file mode 100644 index 0000000..8d76052 --- /dev/null +++ b/app/modules/client_groups/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class ClientGroup(CommonBase): + __tablename__ = "client_groups" + __table_args__ = ( + UniqueConstraint("tenant_id", "group_code", name="uq_client_groups_tenant_code"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + group_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + group_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + group_type: Mapped[str] = mapped_column(String(50), nullable=False, default="Family", index=True) + primary_contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + primary_contact_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + primary_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + primary_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/app/modules/client_groups/service.py b/app/modules/client_groups/service.py new file mode 100644 index 0000000..e868f8b --- /dev/null +++ b/app/modules/client_groups/service.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.modules.client_groups.models import ClientGroup +from app.modules.clients.models import Client + +GROUP_TYPES = ("Family", "Business Group", "Promoter Group", "Trust Group", "Common Management", "Other") + + +def normalise_group_code(value: str | None) -> str: + return (value or "").strip().upper() + + +def list_groups(db: Session, *, tenant_id: int, include_inactive: bool = False): + stmt = ( + select(ClientGroup, func.count(Client.id).label("client_count")) + .outerjoin(Client, Client.client_group_id == ClientGroup.id) + .where(ClientGroup.tenant_id == tenant_id) + .group_by(ClientGroup.id) + .order_by(ClientGroup.group_name.asc()) + ) + if not include_inactive: + stmt = stmt.where(ClientGroup.is_active.is_(True)) + return [{"group": group, "client_count": int(count or 0)} for group, count in db.execute(stmt).all()] + + +def get_group(db: Session, *, tenant_id: int, group_id: int): + return db.execute(select(ClientGroup).where(ClientGroup.id == group_id, ClientGroup.tenant_id == tenant_id)).scalar_one_or_none() + + +def get_group_by_code(db: Session, *, tenant_id: int, group_code: str): + code = normalise_group_code(group_code) + if not code: + return None + return db.execute(select(ClientGroup).where(ClientGroup.tenant_id == tenant_id, ClientGroup.group_code == code)).scalar_one_or_none() + + +def list_group_clients(db: Session, *, tenant_id: int, group_id: int): + return db.execute(select(Client).where(Client.tenant_id == tenant_id, Client.client_group_id == group_id, Client.is_archived.is_(False)).order_by(Client.is_group_head.desc(), Client.client_name.asc())).scalars().all() + + +def create_group(db: Session, *, tenant_id: int, actor_user_id: int, payload: dict): + code = normalise_group_code(payload.get("group_code")) + name = (payload.get("group_name") or "").strip() + if not code or not name: + raise ValueError("Group code and group name are required.") + if get_group_by_code(db, tenant_id=tenant_id, group_code=code): + raise ValueError("Client group code already exists in this firm.") + group_type = (payload.get("group_type") or "Family").strip() + if group_type not in GROUP_TYPES: + raise ValueError("Invalid group type.") + row = ClientGroup( + tenant_id=tenant_id, group_code=code, group_name=name, group_type=group_type, + primary_contact_name=(payload.get("primary_contact_name") or "").strip() or None, + primary_contact_mobile=(payload.get("primary_contact_mobile") or "").strip() or None, + primary_contact_email=(payload.get("primary_contact_email") or "").strip().lower() or None, + assigned_partner_user_id=payload.get("assigned_partner_user_id") or None, + primary_consultant_id=payload.get("primary_consultant_id") or None, + notes=(payload.get("notes") or "").strip() or None, + is_active=bool(payload.get("is_active", True)), created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, + ) + db.add(row); db.commit(); db.refresh(row); return row + + +def update_group(db: Session, *, row: ClientGroup, actor_user_id: int, payload: dict): + code = normalise_group_code(payload.get("group_code")) + name = (payload.get("group_name") or "").strip() + if not code or not name: + raise ValueError("Group code and group name are required.") + duplicate = get_group_by_code(db, tenant_id=row.tenant_id, group_code=code) + if duplicate and duplicate.id != row.id: + raise ValueError("Client group code already exists in this firm.") + group_type = (payload.get("group_type") or "Family").strip() + if group_type not in GROUP_TYPES: + raise ValueError("Invalid group type.") + for key, value in { + "group_code": code, "group_name": name, "group_type": group_type, + "primary_contact_name": (payload.get("primary_contact_name") or "").strip() or None, + "primary_contact_mobile": (payload.get("primary_contact_mobile") or "").strip() or None, + "primary_contact_email": (payload.get("primary_contact_email") or "").strip().lower() or None, + "assigned_partner_user_id": payload.get("assigned_partner_user_id") or None, + "primary_consultant_id": payload.get("primary_consultant_id") or None, + "notes": (payload.get("notes") or "").strip() or None, + "is_active": bool(payload.get("is_active", False)), "updated_by_user_id": actor_user_id, + }.items(): setattr(row, key, value) + db.add(row); db.commit(); db.refresh(row); return row + + +def resolve_or_create_group(db: Session, *, tenant_id: int, actor_user_id: int, group_code: str | None, group_name: str | None, group_type: str | None = None): + code = normalise_group_code(group_code) + name = (group_name or "").strip() + if not code and not name: + return None + if not code: + raise ValueError("client_group_code is required when client_group_name is supplied.") + existing = get_group_by_code(db, tenant_id=tenant_id, group_code=code) + if existing: + if name and existing.group_name.strip().lower() != name.lower(): + raise ValueError(f"Client group code {code} already exists with name {existing.group_name}.") + return existing + if not name: + raise ValueError(f"Client group {code} does not exist; provide client_group_name to create it.") + return create_group(db, tenant_id=tenant_id, actor_user_id=actor_user_id, payload={"group_code": code, "group_name": name, "group_type": group_type or "Family", "is_active": True}) diff --git a/app/modules/client_groups/templates/client_groups/detail.html b/app/modules/client_groups/templates/client_groups/detail.html new file mode 100644 index 0000000..10d12d3 --- /dev/null +++ b/app/modules/client_groups/templates/client_groups/detail.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}
{{ row.group_code }} · {{ row.group_type }}

{{ row.group_name }}

Edit Group
Primary contact
{{ row.primary_contact_name or '-' }}
{{ row.primary_contact_mobile or '' }} {{ row.primary_contact_email or '' }}
Members
{{ clients|length }}
Status
{{ 'Active' if row.is_active else 'Inactive' }}
{% for client in clients %}{% else %}{% endfor %}
ClientRelationshipPAN
{{ client.client_name }}
{{ client.client_code }}{% if client.is_group_head %} · Group Head{% endif %}
{{ client.group_relationship or '-' }}{{ client.pan or '-' }}Open
No clients linked.
{% endblock %} \ No newline at end of file diff --git a/app/modules/client_groups/templates/client_groups/form.html b/app/modules/client_groups/templates/client_groups/form.html new file mode 100644 index 0000000..d3f37fa --- /dev/null +++ b/app/modules/client_groups/templates/client_groups/form.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

{{ title }}

{% for error in errors %}
{{ error }}
{% endfor %}
{% endblock %} \ No newline at end of file diff --git a/app/modules/client_groups/templates/client_groups/list.html b/app/modules/client_groups/templates/client_groups/list.html new file mode 100644 index 0000000..d0f8f15 --- /dev/null +++ b/app/modules/client_groups/templates/client_groups/list.html @@ -0,0 +1 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %}

Client Groups

Track family, promoter and related business clients together.

Add Group
{% for item in groups %}{% else %}{% endfor %}
CodeGroupTypeClients
{{ item.group.group_code }}
{{ item.group.group_name }}
{{ item.group.primary_contact_name or '-' }}
{{ item.group.group_type }}{{ item.client_count }}Open
No client groups created.
{% endblock %} \ No newline at end of file diff --git a/app/modules/client_groups/ui.py b/app/modules/client_groups/ui.py new file mode 100644 index 0000000..3119237 --- /dev/null +++ b/app/modules/client_groups/ui.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from app.core.db import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.security.deps import get_current_user +from app.core.templating import templates +from app.modules.core.rbac.service import get_user_permissions, get_user_roles, require_permission +from app.modules.client_groups.service import GROUP_TYPES, create_group, get_group, list_group_clients, list_groups, update_group +from app.modules.clients.access import build_scope +from app.modules.clients import repository +from app.modules.consultants.service import list_consultants + +router = APIRouter(prefix="/client-groups", tags=["client-groups-ui"]) + + +def _ctx(request, user, db, **extra): + data={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}; data.update(extra); return data + + +def _payload(form): + def opt_int(name): + value=form.get(name); return int(value) if value not in (None,"","None") else None + return {"group_code":form.get("group_code"),"group_name":form.get("group_name"),"group_type":form.get("group_type") or "Family","primary_contact_name":form.get("primary_contact_name"),"primary_contact_mobile":form.get("primary_contact_mobile"),"primary_contact_email":form.get("primary_contact_email"),"assigned_partner_user_id":opt_int("assigned_partner_user_id"),"primary_consultant_id":opt_int("primary_consultant_id"),"notes":form.get("notes"),"is_active":form.get("is_active") in ("1","on","true","yes")} + +@router.get("") +def groups_page(request: Request): + db=CommonSessionLocal() + try: + user=get_current_user(request,db=db) + if not user: return RedirectResponse('/login',303) + require_permission(db,user,'clients.view'); scope=build_scope(request,user,lambda code: True) + return templates.TemplateResponse('modules/client_groups/templates/client_groups/list.html',_ctx(request,user,db,title='Client Groups',groups=list_groups(db,tenant_id=scope.tenant_id,include_inactive=True))) + finally: db.close() + +@router.get("/new") +def group_new(request: Request): + db=CommonSessionLocal() + try: + user=get_current_user(request,db=db) + if not user: return RedirectResponse('/login',303) + require_permission(db,user,'clients.create'); scope=build_scope(request,user,lambda code: True) + return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Add Client Group',row=None,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[])) + finally: db.close() + +@router.post("") +async def group_create(request: Request): + db=CommonSessionLocal() + try: + user=get_current_user(request,db=db); form=await request.form(); validate_csrf(request,form.get('csrf_token')) + if not user: return RedirectResponse('/login',303) + require_permission(db,user,'clients.create'); scope=build_scope(request,user,lambda code: True) + try: row=create_group(db,tenant_id=scope.tenant_id,actor_user_id=user.id,payload=_payload(form)); return RedirectResponse(f'/client-groups/{row.id}',303) + except Exception as exc: return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Add Client Group',row=_payload(form),group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[str(exc)]),status_code=400) + finally: db.close() + +@router.get("/{group_id}") +def group_detail(request: Request, group_id:int): + db=CommonSessionLocal() + try: + user=get_current_user(request,db=db) + if not user: return RedirectResponse('/login',303) + require_permission(db,user,'clients.view'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id) + if not row: return RedirectResponse('/client-groups',303) + return templates.TemplateResponse('modules/client_groups/templates/client_groups/detail.html',_ctx(request,user,db,title=row.group_name,row=row,clients=list_group_clients(db,tenant_id=scope.tenant_id,group_id=row.id))) + finally: db.close() + +@router.get("/{group_id}/edit") +def group_edit(request: Request, group_id:int): + db=CommonSessionLocal() + try: + user=get_current_user(request,db=db) + if not user: return RedirectResponse('/login',303) + require_permission(db,user,'clients.edit'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id) + if not row: return RedirectResponse('/client-groups',303) + return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Edit Client Group',row=row,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[])) + finally: db.close() + +@router.post("/{group_id}/edit") +async def group_update(request: Request, group_id:int): + db=CommonSessionLocal() + try: + user=get_current_user(request,db=db); form=await request.form(); validate_csrf(request,form.get('csrf_token')) + if not user: return RedirectResponse('/login',303) + require_permission(db,user,'clients.edit'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id) + if not row: return RedirectResponse('/client-groups',303) + try: update_group(db,row=row,actor_user_id=user.id,payload=_payload(form)); return RedirectResponse(f'/client-groups/{row.id}',303) + except Exception as exc: return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Edit Client Group',row=row,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[str(exc)]),status_code=400) + finally: db.close() diff --git a/app/modules/clients/filters.py b/app/modules/clients/filters.py index d1e9976..e3cba75 100644 --- a/app/modules/clients/filters.py +++ b/app/modules/clients/filters.py @@ -6,6 +6,7 @@ class ClientListFilters: q: str = "" status: str = "" client_type: str = "" + client_group_id: int | None = None partner_id: int | None = None include_archived: bool = False page: int = 1 @@ -20,6 +21,9 @@ class ClientListFilters: partner_id = None elif not isinstance(partner_id, int): partner_id = int(partner_id) + client_group_id = kwargs.get("client_group_id") + if client_group_id in ("", None): client_group_id = None + elif not isinstance(client_group_id, int): client_group_id = int(client_group_id) include_archived = kwargs.get("include_archived", False) if isinstance(include_archived, str): include_archived = include_archived.lower() in ("1", "true", "yes", "on") @@ -27,6 +31,7 @@ class ClientListFilters: q=kwargs.get("q", "") or "", status=kwargs.get("status", "") or "", client_type=kwargs.get("client_type", "") or "", + client_group_id=client_group_id, partner_id=partner_id, include_archived=include_archived, page=max(int(kwargs.get("page", 1) or 1), 1), diff --git a/app/modules/clients/import_service.py b/app/modules/clients/import_service.py index f842812..4535c70 100644 --- a/app/modules/clients/import_service.py +++ b/app/modules/clients/import_service.py @@ -11,6 +11,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.modules.client_identity.service import ensure_identity, normalize_pan, placeholder_email +from app.modules.client_groups.service import get_group_by_code, resolve_or_create_group from app.modules.clients import repository from app.modules.clients.models import Client from app.modules.clients.schemas import ClientCreate @@ -31,6 +32,11 @@ TEMPLATE_COLUMNS = [ "referral_status", "communication_routing_mode", "branch_id", + "client_group_code", + "client_group_name", + "group_type", + "group_relationship", + "is_group_head", "client_code", "client_name", "client_type", @@ -75,6 +81,7 @@ TEMPLATE_COLUMNS = [ ] BOOL_FIELDS = { + "is_group_head", "gst_applicable", "income_tax_applicable", "tds_applicable", @@ -239,6 +246,11 @@ def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_ "partner_user_id": "Must be an active Partner user in the same firm.", "referred_by_consultant_id": "Optional active consultant profile id who introduced the client.", "primary_consultant_id": "Optional active consultant profile id for the operational client link.", + "client_group_code": "Optional tenant-unique group code. Existing group is matched by this code.", + "client_group_name": "Required only when creating a new group during import.", + "group_type": "Family, Business Group, Promoter Group, Trust Group, Common Management, or Other.", + "group_relationship": "Optional relationship such as Spouse, HUF, Company, Trust, or Related Concern.", + "is_group_head": "yes/no. Only one group head is recommended per group.", "referral_date": "Optional referral date supported by the existing client schema.", "referral_reference": "Optional referral or source reference.", "referral_status": "active, inactive, ended, or pending.", @@ -407,6 +419,9 @@ def build_preview( "tenant_id": firm_tenant_id, "branch_id": branch_id, "partner_id": partner_user_id or None, + "client_group_id": None, + "group_relationship": cleaned.get("group_relationship"), + "is_group_head": bool(cleaned.get("is_group_head")), "referred_by_consultant_id": referred_id, "primary_consultant_id": primary_id, "referral_date": cleaned.get("referral_date"), @@ -470,6 +485,9 @@ def build_preview( "tenant_id": firm_tenant_id, "branch_id": branch_id, "partner_id": partner_user_id, + "client_group_code": cleaned.get("client_group_code"), + "client_group_name": cleaned.get("client_group_name"), + "group_type": cleaned.get("group_type") or "Family", "client_payload": payload, "portal_password": password, "portal_password_confirm": password_confirm, @@ -530,6 +548,16 @@ def commit_import( if existing_user: raise ValueError("Email is already used by another ERP login.") + group = resolve_or_create_group( + db, tenant_id=int(payload["tenant_id"]), actor_user_id=current_user.id, + group_code=item.get("client_group_code"), group_name=item.get("client_group_name"), group_type=item.get("group_type"), + ) + payload["client_group_id"] = group.id if group else None + if group and payload.get("is_group_head"): + existing_head = db.execute(select(Client).where(Client.tenant_id == int(payload["tenant_id"]), Client.client_group_id == group.id, Client.is_group_head.is_(True))).scalar_one_or_none() + if existing_head: + raise ValueError(f"Group {group.group_code} already has group head {existing_head.client_code} - {existing_head.client_name}.") + data = ClientCreate(**payload) row = create_client_service( db, diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py index ea350f3..bbea0a1 100644 --- a/app/modules/clients/models.py +++ b/app/modules/clients/models.py @@ -20,6 +20,9 @@ class Client(CommonBase): tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True) branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + client_group_id: Mapped[int | None] = mapped_column(ForeignKey("client_groups.id", ondelete="SET NULL"), nullable=True, index=True) + group_relationship: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_group_head: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) referred_by_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True) referral_date: Mapped[date | None] = mapped_column(Date, nullable=True) diff --git a/app/modules/clients/repository.py b/app/modules/clients/repository.py index 66b6ed4..e546cff 100644 --- a/app/modules/clients/repository.py +++ b/app/modules/clients/repository.py @@ -9,6 +9,7 @@ from app.core.security.passwords import hash_password from app.modules.clients.association_models import ClientAssociation from app.modules.clients.constants import CLIENT_SORT_FIELDS from app.modules.clients.models import Client, ClientAuditLog +from app.modules.client_groups.models import ClientGroup 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, Tenant @@ -30,16 +31,18 @@ def build_clients_query( q: str = "", status: str = "", client_type: str = "", + client_group_id: int | None = None, include_archived: bool = False, ): assoc = ClientAssociation - stmt = ( select( Client, User.full_name.label("partner_name"), Branch.name.label("branch_name"), Tenant.name.label("tenant_name"), + ClientGroup.group_name.label("client_group_name"), + ClientGroup.group_code.label("client_group_code"), assoc.association_type.label("association_type"), assoc.firm_tenant_id.label("assoc_firm_tenant_id"), assoc.consultant_id.label("assoc_consultant_id"), @@ -47,189 +50,85 @@ def build_clients_query( assoc.created_source.label("assoc_created_source"), ) .outerjoin(assoc, assoc.client_id == Client.id) + .outerjoin(ClientGroup, ClientGroup.id == Client.client_group_id) .join(User, User.id == Client.partner_id, isouter=True) .join(Branch, Branch.id == Client.branch_id, isouter=True) .join(Tenant, Tenant.id == Client.tenant_id, isouter=True) ) - if not allow_all_clients: stmt = stmt.where(Client.tenant_id == tenant_id) - if not include_archived: stmt = stmt.where(Client.is_archived.is_(False)) - if branch_id and not allow_all_clients and not allow_cross_branch: stmt = stmt.where(Client.branch_id == branch_id) - if partner_id: - stmt = stmt.where( - (Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id) - ) - + stmt = stmt.where((Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id)) if status: stmt = stmt.where(Client.status == status) - if client_type: stmt = stmt.where(Client.client_type == client_type) - + if client_group_id: + stmt = stmt.where(Client.client_group_id == client_group_id) if q: like = f"%{q.strip()}%" - stmt = stmt.where( - or_( - Client.client_code.ilike(like), - Client.client_name.ilike(like), - Client.trade_name.ilike(like), - Client.pan.ilike(like), - Client.gstin.ilike(like), - Client.mobile.ilike(like), - Client.email.ilike(like), - ) - ) - + stmt = stmt.where(or_( + Client.client_code.ilike(like), Client.client_name.ilike(like), Client.trade_name.ilike(like), + Client.pan.ilike(like), Client.gstin.ilike(like), Client.mobile.ilike(like), Client.email.ilike(like), + ClientGroup.group_name.ilike(like), ClientGroup.group_code.ilike(like), + )) return stmt def list_clients( - db: Session, - *, - tenant_id: int, - branch_id: int | None = None, - allow_cross_branch: bool = False, - allow_all_clients: bool = False, - partner_id: int | None = None, - q: str = "", - status: str = "", - client_type: str = "", - include_archived: bool = False, - page: int = 1, - per_page: int = 10, - sort_by: str = "client_name", - sort_order: str = "asc", + db: Session, *, tenant_id: int, branch_id: int | None = None, + allow_cross_branch: bool = False, allow_all_clients: bool = False, + partner_id: int | None = None, q: str = "", status: str = "", client_type: str = "", + client_group_id: int | None = None, include_archived: bool = False, + page: int = 1, per_page: int = 10, sort_by: str = "client_name", sort_order: str = "asc", ) -> dict: stmt = build_clients_query( - tenant_id=tenant_id, - branch_id=branch_id, - allow_cross_branch=allow_cross_branch, - allow_all_clients=allow_all_clients, - partner_id=partner_id, - q=q, - status=status, - client_type=client_type, - include_archived=include_archived, + tenant_id=tenant_id, branch_id=branch_id, allow_cross_branch=allow_cross_branch, + allow_all_clients=allow_all_clients, partner_id=partner_id, q=q, status=status, + client_type=client_type, client_group_id=client_group_id, include_archived=include_archived, ) - total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() - result = db.execute( - stmt.order_by(_safe_sort(sort_by, sort_order)) - .offset((page - 1) * per_page) - .limit(per_page) - ).all() - - rows = [] - for ( - client, - partner_name, - branch_name, - tenant_name, - association_type, - assoc_firm_tenant_id, - assoc_consultant_id, - assoc_partner_user_id, - assoc_created_source, - ) in result: - row = {**client.__dict__} - row.pop("_sa_instance_state", None) - row.update( - { - "partner_name": partner_name, - "branch_name": branch_name, - "tenant_name": tenant_name, - "association_type": association_type, - "assoc_firm_tenant_id": assoc_firm_tenant_id, - "assoc_consultant_id": assoc_consultant_id, - "assoc_partner_user_id": assoc_partner_user_id, - "assoc_created_source": assoc_created_source, - "effective_partner_id": assoc_partner_user_id or row.get("partner_id"), - } - ) - rows.append(row) - - stats_stmt = select( - func.count(Client.id), - func.sum(case((Client.status == "active", 1), else_=0)), - func.sum(case((Client.status == "inactive", 1), else_=0)), - func.sum(case((Client.status == "archived", 1), else_=0)), - ) - + result = db.execute(stmt.order_by(_safe_sort(sort_by, sort_order)).offset((page - 1) * per_page).limit(per_page)).all() + rows=[] + for client, partner_name, branch_name, tenant_name, client_group_name, client_group_code, association_type, assoc_firm_tenant_id, assoc_consultant_id, assoc_partner_user_id, assoc_created_source in result: + row={**client.__dict__}; row.pop("_sa_instance_state",None) + row.update({ + "partner_name":partner_name,"branch_name":branch_name,"tenant_name":tenant_name, + "client_group_name":client_group_name,"client_group_code":client_group_code, + "association_type":association_type,"assoc_firm_tenant_id":assoc_firm_tenant_id, + "assoc_consultant_id":assoc_consultant_id,"assoc_partner_user_id":assoc_partner_user_id, + "assoc_created_source":assoc_created_source,"effective_partner_id":assoc_partner_user_id or row.get("partner_id"), + }); rows.append(row) + stats_stmt=select(func.count(Client.id),func.sum(case((Client.status=="active",1),else_=0)),func.sum(case((Client.status=="inactive",1),else_=0)),func.sum(case((Client.status=="archived",1),else_=0))) if not allow_all_clients: - stats_stmt = stats_stmt.where(Client.tenant_id == tenant_id) - if branch_id and not allow_cross_branch: - stats_stmt = stats_stmt.where(Client.branch_id == branch_id) - - if partner_id: - stats_stmt = stats_stmt.where(Client.partner_id == partner_id) - - total_all, active, inactive, archived = db.execute(stats_stmt).one() - - pages = ceil(total / per_page) if per_page else 1 - return { - "rows": rows, - "meta": { - "total": total, - "page": page, - "per_page": per_page, - "pages": max(pages, 1), - }, - "stats": { - "total": int(total_all or 0), - "active": int(active or 0), - "inactive": int(inactive or 0), - "archived": int(archived or 0), - }, - } + stats_stmt=stats_stmt.where(Client.tenant_id==tenant_id) + if branch_id and not allow_cross_branch: stats_stmt=stats_stmt.where(Client.branch_id==branch_id) + if partner_id: stats_stmt=stats_stmt.where(Client.partner_id==partner_id) + total_all,active,inactive,archived=db.execute(stats_stmt).one() + pages=ceil(total/per_page) if per_page else 1 + return {"rows":rows,"meta":{"total":total,"page":page,"per_page":per_page,"pages":max(pages,1)},"stats":{"total":int(total_all or 0),"active":int(active or 0),"inactive":int(inactive or 0),"archived":int(archived or 0)}} def get_client_detail_payload(db: Session, client_id: int): - assoc = ClientAssociation - - stmt = ( - select( - Client, - assoc.association_type.label("association_type"), - assoc.firm_tenant_id.label("assoc_firm_tenant_id"), - assoc.consultant_id.label("assoc_consultant_id"), - assoc.partner_user_id.label("assoc_partner_user_id"), - assoc.created_source.label("assoc_created_source"), - ) - .outerjoin(assoc, assoc.client_id == Client.id) - .where(Client.id == client_id) - ) - - result = db.execute(stmt).one_or_none() - if not result: - return None - - ( - client, - association_type, - assoc_firm_tenant_id, - assoc_consultant_id, - assoc_partner_user_id, - assoc_created_source, - ) = result - - row = {**client.__dict__} - row.pop("_sa_instance_state", None) - row.update( - { - "association_type": association_type, - "assoc_firm_tenant_id": assoc_firm_tenant_id, - "assoc_consultant_id": assoc_consultant_id, - "assoc_partner_user_id": assoc_partner_user_id, - "assoc_created_source": assoc_created_source, - "effective_partner_id": assoc_partner_user_id or row.get("partner_id"), - } - ) + assoc=ClientAssociation + stmt=(select( + Client, ClientGroup.group_name.label("client_group_name"), ClientGroup.group_code.label("client_group_code"), + assoc.association_type.label("association_type"), assoc.firm_tenant_id.label("assoc_firm_tenant_id"), + assoc.consultant_id.label("assoc_consultant_id"), assoc.partner_user_id.label("assoc_partner_user_id"), + assoc.created_source.label("assoc_created_source"), + ).outerjoin(assoc,assoc.client_id==Client.id).outerjoin(ClientGroup,ClientGroup.id==Client.client_group_id).where(Client.id==client_id)) + result=db.execute(stmt).one_or_none() + if not result: return None + client,client_group_name,client_group_code,association_type,assoc_firm_tenant_id,assoc_consultant_id,assoc_partner_user_id,assoc_created_source=result + row={**client.__dict__}; row.pop("_sa_instance_state",None) + row.update({"client_group_name":client_group_name,"client_group_code":client_group_code,"association_type":association_type, + "assoc_firm_tenant_id":assoc_firm_tenant_id,"assoc_consultant_id":assoc_consultant_id, + "assoc_partner_user_id":assoc_partner_user_id,"assoc_created_source":assoc_created_source, + "effective_partner_id":assoc_partner_user_id or row.get("partner_id")}) return row diff --git a/app/modules/clients/schemas.py b/app/modules/clients/schemas.py index ea33e73..d60e0a7 100644 --- a/app/modules/clients/schemas.py +++ b/app/modules/clients/schemas.py @@ -21,6 +21,9 @@ class ClientBase(BaseModel): tenant_id: int branch_id: int partner_id: Optional[int] = None + client_group_id: Optional[int] = None + group_relationship: Optional[str] = None + is_group_head: bool = False default_review_partner_user_id: Optional[int] = None referred_by_consultant_id: Optional[int] = None primary_consultant_id: Optional[int] = None @@ -88,7 +91,7 @@ class ClientBase(BaseModel): @field_validator( "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", - "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "acceptance_review_notes", "acceptance_rejection_reason", "notes", + "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "group_relationship", "acceptance_review_notes", "acceptance_rejection_reason", "notes", mode="before", ) @classmethod @@ -213,6 +216,9 @@ class ClientUpdate(BaseModel): tenant_id: Optional[int] = None branch_id: Optional[int] = None partner_id: Optional[int] = None + client_group_id: Optional[int] = None + group_relationship: Optional[str] = None + is_group_head: Optional[bool] = None default_review_partner_user_id: Optional[int] = None referred_by_consultant_id: Optional[int] = None primary_consultant_id: Optional[int] = None @@ -272,7 +278,7 @@ class ClientUpdate(BaseModel): @field_validator( "client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", - "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "acceptance_review_notes", "acceptance_rejection_reason", "notes", + "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "group_relationship", "acceptance_review_notes", "acceptance_rejection_reason", "notes", mode="before", ) @classmethod diff --git a/app/modules/clients/service.py b/app/modules/clients/service.py index df8c47b..4869e98 100644 --- a/app/modules/clients/service.py +++ b/app/modules/clients/service.py @@ -30,6 +30,8 @@ from app.modules.core.iam.models import User from app.modules.core.rbac.models import Role, UserRole from app.modules.documents.models import PermanentClientDocument from app.modules.consultants.service import sync_primary_client_consultant_link +from app.modules.client_groups.service import get_group +from app.modules.clients.models import Client @@ -43,6 +45,26 @@ def _payload_from_schema(data): + +def _validate_client_group_assignment(db, *, payload: dict, existing_row=None): + group_id = payload.get("client_group_id") + tenant_id = payload.get("tenant_id") or getattr(existing_row, "tenant_id", None) + if group_id: + group = get_group(db, tenant_id=int(tenant_id), group_id=int(group_id)) + if not group or not group.is_active: + raise HTTPException(status_code=400, detail="Selected client group is invalid or inactive for this firm.") + if payload.get("is_group_head") and group_id: + stmt = select(Client).where(Client.tenant_id == int(tenant_id), Client.client_group_id == int(group_id), Client.is_group_head.is_(True)) + if existing_row is not None: + stmt = stmt.where(Client.id != existing_row.id) + existing_head = db.execute(stmt).scalar_one_or_none() + if existing_head: + raise HTTPException(status_code=400, detail=f"This group already has group head {existing_head.client_code} - {existing_head.client_name}.") + if not group_id: + payload["group_relationship"] = None + payload["is_group_head"] = False + + def _is_high_risk(risk_category: str | None) -> bool: return (risk_category or "").strip().lower() in CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS @@ -271,6 +293,7 @@ def create_client_service(db, *, data, actor_user_id: int, scope, current_user_r payload = _payload_from_schema(data) _enforce_client_acceptance_controls(payload) + _validate_client_group_assignment(db, payload=payload) row = repository.create_client(db, payload) row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm) sync_primary_client_consultant_link(db, client=row, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id) @@ -301,6 +324,7 @@ def update_client_service(db, *, row, data, actor_user_id: int, scope, current_u payload = _payload_from_schema(data) _enforce_client_acceptance_controls(payload, existing_row=row) + _validate_client_group_assignment(db, payload=payload, existing_row=row) if payload.get("pan"): existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"]) @@ -441,6 +465,10 @@ def export_clients_csv(payload: dict) -> str: "pan", "gstin", "partner", + "client_group_code", + "client_group_name", + "group_relationship", + "is_group_head", "association_type", "association_source", ] @@ -455,6 +483,10 @@ def export_clients_csv(payload: dict) -> str: row.get("pan"), row.get("gstin"), row.get("partner_name") or row.get("effective_partner_id"), + row.get("client_group_code"), + row.get("client_group_name"), + row.get("group_relationship"), + row.get("is_group_head"), row.get("association_type"), row.get("assoc_created_source"), ] diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index 111c293..9a01f15 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -60,7 +60,8 @@ {% endif %} -
+
Client Group
{% if client_group %}
{{ client_group.group_code }} · {{ row.group_relationship or client_group.group_type }}{% if row.is_group_head %} · Group Head{% endif %}
{% else %}
Not linked to a group.
{% endif %}
+

Profile

diff --git a/app/modules/clients/templates/clients/list.html b/app/modules/clients/templates/clients/list.html index 5abcd47..28ac998 100644 --- a/app/modules/clients/templates/clients/list.html +++ b/app/modules/clients/templates/clients/list.html @@ -7,7 +7,7 @@

Association-aware list view.

-
-
+ +
+

Client Group / Family

+

Link this client to an existing family, promoter or related business group. Consultant access remains client-specific.

+
+ + + +
+
+
+

Assignment & Scope

diff --git a/app/modules/clients/templates/clients/partials/table.html b/app/modules/clients/templates/clients/partials/table.html index c803328..52b1982 100644 --- a/app/modules/clients/templates/clients/partials/table.html +++ b/app/modules/clients/templates/clients/partials/table.html @@ -1,2 +1,2 @@ -
{% for row in rows %}{% else %}{% endfor %}
CodeClientAssociationPartnerBranchStatus
{{ row.client_code }}
{{ row.client_name }}
{{ row.pan or row.gstin or '-' }}
{{ row.association_type or 'legacy_firm' }}
{{ row.assoc_created_source or 'legacy' }}
{{ row.partner_name or row.effective_partner_id or '-' }}{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}{% if row.status == 'active' %}Active{% elif row.status == 'archived' %}Archived{% else %}Inactive{% endif %}Open
No clients found.
+
{% for row in rows %}{% else %}{% endfor %}
CodeClientGroupAssociationPartnerBranchStatus
{{ row.client_code }}
{{ row.client_name }}
{{ row.pan or row.gstin or '-' }}
{% if row.client_group_name %}{{ row.client_group_name }}
{{ row.group_relationship or row.client_group_code }}
{% else %}-{% endif %}
{{ row.association_type or 'legacy_firm' }}
{{ row.assoc_created_source or 'legacy' }}
{{ row.partner_name or row.effective_partner_id or '-' }}{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}{% if row.status == 'active' %}Active{% elif row.status == 'archived' %}Archived{% else %}Inactive{% endif %}Open
No clients found.
diff --git a/app/modules/clients/ui.py b/app/modules/clients/ui.py index feddf92..7498fc6 100644 --- a/app/modules/clients/ui.py +++ b/app/modules/clients/ui.py @@ -55,6 +55,7 @@ from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.permission_guard import require_permission from app.modules.services.execution import list_client_visible_task_comments from app.modules.clients.auditor_service import build_client_auditor_card +from app.modules.client_groups.service import get_group, list_groups from app.modules.clients.portal_service import ( build_client_portal_summary, create_client_reply, @@ -173,6 +174,9 @@ def _build_form_payload(request: Request, user, scope, *, include_client_code: b "tenant_id": tenant_id, "branch_id": branch_id, "partner_id": partner_id, + "client_group_id": int(form.get("client_group_id")) if form.get("client_group_id") not in (None, "", "None") else None, + "group_relationship": form.get("group_relationship"), + "is_group_head": _form_bool(form.get("is_group_head")), "default_review_partner_user_id": int(form.get("default_review_partner_user_id")) if form.get("default_review_partner_user_id") not in (None, "", "None") else None, "referred_by_consultant_id": int(form.get("referred_by_consultant_id")) if form.get("referred_by_consultant_id") not in (None, "", "None") else None, "primary_consultant_id": int(form.get("primary_consultant_id")) if form.get("primary_consultant_id") not in (None, "", "None") else None, @@ -260,6 +264,7 @@ def _form_options(db, scope, form_mode: str): "active_tenant_id": tenant_id, "active_branch_id": branch_id, "consultants": list_consultants(db, tenant_id=tenant_id, include_inactive=False) if tenant_id else [], + "client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)] if tenant_id else [], } return { @@ -271,6 +276,7 @@ def _form_options(db, scope, form_mode: str): branch_id=None if scope.allow_cross_branch else branch_id, ), "consultants": list_consultants(db, tenant_id=tenant_id, branch_id=branch_id, include_inactive=False), + "client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)], "review_partners": repository.list_partners_for_scope( db, tenant_id=tenant_id, @@ -287,6 +293,7 @@ def clients_list( q: str = "", status: str = "", client_type: str = "", + client_group_id: int | None = None, partner_id: int | None = None, include_archived: bool = False, page: int = 1, @@ -313,6 +320,7 @@ def clients_list( status=status, client_type=client_type, partner_id=partner_id, + client_group_id=client_group_id, include_archived=include_archived, page=page, per_page=per_page, @@ -332,6 +340,7 @@ def clients_list( q=filters.q, status=filters.status, client_type=filters.client_type, + client_group_id=filters.client_group_id, include_archived=filters.include_archived, page=filters.page, per_page=filters.per_page, @@ -354,6 +363,7 @@ def clients_list( status=filters.status, client_type=filters.client_type, partner_id=filters.partner_id, + client_group_id=filters.client_group_id, include_archived=filters.include_archived, page=filters.page, per_page=filters.per_page, @@ -697,6 +707,7 @@ def client_detail(request: Request, client_id: int): can_archive=has("clients.archive"), can_restore=has("clients.restore"), consultant_summary=get_client_consultant_summary(db, tenant_id=int(row["tenant_id"]), client_id=client_id), + client_group=get_group(db, tenant_id=int(row["tenant_id"]), group_id=int(row["client_group_id"])) if row.get("client_group_id") else None, can_manage_acceptance=has("clients.acceptance.manage"), can_approve_acceptance=has("clients.acceptance.approve"), ) diff --git a/app/ui/app.py b/app/ui/app.py index 4435fb0..4ce9e55 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.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 from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router from app.modules.manager_dashboard.ui import router as manager_dashboard_router @@ -67,6 +68,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_groups_ui_router) app.include_router(registrations_ui_router) app.include_router(credential_vault_ui_router) app.include_router(client_identity_ui_router)