diff --git a/app/modules/consultants/service.py b/app/modules/consultants/service.py index 36b82e3..a9265cf 100644 --- a/app/modules/consultants/service.py +++ b/app/modules/consultants/service.py @@ -326,6 +326,122 @@ def list_client_links_for_consultant(db: Session, *, consultant_id: int) -> list ).scalars().all() +def consultant_linked_clients_page( + db: Session, + *, + consultant: ConsultantProfile, + q: str = "", + client_group_id: int | None = None, + status: str = "active", + page: int = 1, + per_page: int = 25, + sort_by: str = "client_name", + sort_order: str = "asc", +) -> dict: + """Return a compact, searchable and paginated list of linked firm clients. + + Link permission columns remain unchanged and continue to govern the existing + service, due-date, communication and document workflows. They are deliberately + not exposed in the client-list UI. + """ + q = (q or "").strip() + status = (status or "").strip().lower() + per_page = min(max(int(per_page or 25), 1), 100) + page = max(int(page or 1), 1) + + stmt = ( + select(ClientConsultantLink, Client, ClientGroup) + .join(Client, Client.id == ClientConsultantLink.client_id) + .outerjoin(ClientGroup, ClientGroup.id == Client.client_group_id) + .where( + ClientConsultantLink.tenant_id == consultant.tenant_id, + ClientConsultantLink.consultant_id == consultant.id, + ClientConsultantLink.is_active.is_(True), + Client.tenant_id == consultant.tenant_id, + Client.is_archived.is_(False), + ) + ) + if status: + stmt = stmt.where(Client.status == status) + if client_group_id: + stmt = stmt.where(Client.client_group_id == int(client_group_id)) + if q: + like = f"%{q}%" + 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), + ClientGroup.group_name.ilike(like), + ClientGroup.group_code.ilike(like), + ) + ) + + total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() or 0) + pages = max(ceil(total / per_page), 1) + page = min(page, pages) + + sort_columns = { + "client_code": Client.client_code, + "client_name": Client.client_name, + "pan": Client.pan, + "client_group": ClientGroup.group_name, + "status": Client.status, + } + sort_column = sort_columns.get(sort_by, Client.client_name) + ordering = sort_column.desc() if (sort_order or "asc").lower() == "desc" else sort_column.asc() + + rows = [] + result = db.execute( + stmt.order_by(ordering, Client.id.asc()) + .offset((page - 1) * per_page) + .limit(per_page) + ).all() + for link, client, group in result: + rows.append({ + "link": link, + "client": client, + "client_group_id": getattr(group, "id", None), + "client_group_code": getattr(group, "group_code", None), + "client_group_name": getattr(group, "group_name", None), + "relationship_label": (link.relationship_type or "accounts_consultant").replace("_", " ").title(), + }) + + groups_stmt = ( + select(ClientGroup.id, ClientGroup.group_code, ClientGroup.group_name) + .join(Client, Client.client_group_id == ClientGroup.id) + .join(ClientConsultantLink, ClientConsultantLink.client_id == Client.id) + .where( + ClientConsultantLink.tenant_id == consultant.tenant_id, + ClientConsultantLink.consultant_id == consultant.id, + ClientConsultantLink.is_active.is_(True), + Client.tenant_id == consultant.tenant_id, + Client.is_archived.is_(False), + ClientGroup.is_active.is_(True), + ) + .distinct() + .order_by(ClientGroup.group_name.asc()) + ) + groups = [ + {"id": int(group_id), "code": code, "name": name} + for group_id, code, name in db.execute(groups_stmt).all() + ] + return { + "rows": rows, + "groups": groups, + "meta": {"total": total, "page": page, "per_page": per_page, "pages": pages}, + "filters": { + "q": q, + "client_group_id": client_group_id, + "status": status, + "sort_by": sort_by if sort_by in sort_columns else "client_name", + "sort_order": "desc" if (sort_order or "asc").lower() == "desc" else "asc", + }, + } + + def link_client_to_consultant( db: Session, *, diff --git a/app/modules/consultants/templates/consultants/portal_dashboard.html b/app/modules/consultants/templates/consultants/portal_dashboard.html index c8d018e..6f2e313 100644 --- a/app/modules/consultants/templates/consultants/portal_dashboard.html +++ b/app/modules/consultants/templates/consultants/portal_dashboard.html @@ -38,6 +38,8 @@ {% include "modules/consultants/templates/consultants/portal_partials/clients_requests.html" %} {% elif active_tab == 'documents-clarifications' %} {% include "modules/consultants/templates/consultants/portal_partials/documents_clarifications.html" %} + {% elif active_tab == 'reports' %} + {% include "modules/consultants/templates/consultants/portal_partials/reports.html" %} {% else %} {% include "modules/consultants/templates/consultants/portal_partials/overview.html" %} {% endif %} diff --git a/app/modules/consultants/templates/consultants/portal_partials/clients.html b/app/modules/consultants/templates/consultants/portal_partials/clients.html index 0cc9f7f..876fb3c 100644 --- a/app/modules/consultants/templates/consultants/portal_partials/clients.html +++ b/app/modules/consultants/templates/consultants/portal_partials/clients.html @@ -1,10 +1,148 @@ -
-
+{% set linked = payload.linked_clients_page %} +{% set filters = linked.filters %} +{% set meta = linked.meta %} + +
+
+
+
+

Linked Firm Clients

+

Firm clients explicitly linked to your consultant profile.

+
+ {{ meta.total }} client{% if meta.total != 1 %}s{% endif %} +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + Clear +
+
+ +
+ + + + + + + + + + + + + {% for row in linked.rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
CodeClient NamePANClient GroupRoleStatus
{{ row.client.client_code or '-' }} +
{{ row.client.client_name }}
+ {% if row.client.trade_name and row.client.trade_name != row.client.client_name %}
{{ row.client.trade_name }}
{% endif %} +
{{ row.client.pan or '-' }} + {% if row.client_group_name %} +
{{ row.client_group_name }}
+ {% if row.client_group_code %}
{{ row.client_group_code }}
{% endif %} + {% else %}-{% endif %} +
{{ row.relationship_label }} + {% if row.client.status == 'active' %} + Active + {% else %} + {{ (row.client.status or 'inactive').replace('_',' ').title() }} + {% endif %} +
No linked firm clients match the selected filters.
+
+ +
+
+ {% if meta.total %} + Showing {{ ((meta.page - 1) * meta.per_page) + 1 }} to + {{ meta.total if meta.page * meta.per_page > meta.total else meta.page * meta.per_page }} + of {{ meta.total }} clients + {% else %}No clients found{% endif %} +
+
+
+ + + {% if filters.client_group_id %}{% endif %} + + + + + + +
+ {% set query = namespace(value='tab=clients&linked_q=' ~ (filters.q|urlencode) ~ '&linked_status=' ~ (filters.status|urlencode) ~ '&linked_per_page=' ~ meta.per_page ~ '&linked_sort_by=' ~ (filters.sort_by|urlencode) ~ '&linked_sort_order=' ~ (filters.sort_order|urlencode)) %} + {% if filters.client_group_id %}{% set query.value = query.value ~ '&linked_group_id=' ~ filters.client_group_id %}{% endif %} + {% if meta.page > 1 %}Previous{% endif %} + {% for number in range(1, meta.pages + 1) %} + {% if number == meta.page %} + {{ number }} + {% elif number == 1 or number == meta.pages or (number >= meta.page - 2 and number <= meta.page + 2) %} + {{ number }} + {% elif number == meta.page - 3 or number == meta.page + 3 %}{% endif %} + {% endfor %} + {% if meta.page < meta.pages %}Next{% endif %} +
+
+
+ +
-

Managed Clients

Clients maintained by the consultant workspace.

+
+

Managed Clients

+

Clients maintained separately within the consultant workspace.

+
View all
-
+
{% for client in payload.managed_clients %}
{{ client.client_name }}
@@ -12,31 +150,8 @@
Conversion: {{ client.conversion_status.replace('_',' ').title() if client.conversion_status else 'Not Requested' }}
{% else %} -
No managed clients yet.
+
No managed clients yet.
{% endfor %}
-
- -
-
-

Linked Firm Clients

Only explicitly linked firm clients are visible.

- {{ payload.active_links|length }} active -
-
- {% for link in payload.active_links %} -
-
{{ link.client.client_name if link.client else '-' }}
-
{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}
-
- Client {{ 'visible' if link.can_view_client else 'hidden' }} - Services {{ 'visible' if link.can_view_services else 'hidden' }} - Due dates {{ 'visible' if link.can_view_due_dates else 'hidden' }} - Communications {{ 'visible' if link.can_view_communications else 'hidden' }} -
-
- {% else %} -
No firm clients are linked yet.
- {% endfor %} -
-
+
diff --git a/app/modules/consultants/ui.py b/app/modules/consultants/ui.py index 89d30d3..09a3a5d 100644 --- a/app/modules/consultants/ui.py +++ b/app/modules/consultants/ui.py @@ -27,6 +27,7 @@ from app.modules.consultants.service import ( approve_managed_client_conversion, consultant_can_add_managed_client, consultant_dashboard_payload, + consultant_linked_clients_page, create_consultant_service_request, create_or_update_consultant, create_or_update_managed_client, @@ -1349,6 +1350,12 @@ def _consultant_empty_payload() -> dict: return { "links": [], "active_links": [], + "linked_clients_page": { + "rows": [], + "groups": [], + "meta": {"total": 0, "page": 1, "per_page": 25, "pages": 1}, + "filters": {"q": "", "client_group_id": None, "status": "active", "sort_by": "client_name", "sort_order": "asc"}, + }, "comments": [], "recent_firm_messages": [], "recent_consultant_replies": [], @@ -1390,8 +1397,30 @@ def _consultant_empty_payload() -> dict: } -def _build_consultant_portal_payload(db, consultant) -> dict: +def _build_consultant_portal_payload( + db, + consultant, + *, + linked_q: str = "", + linked_group_id: int | None = None, + linked_status: str = "active", + linked_page: int = 1, + linked_per_page: int = 25, + linked_sort_by: str = "client_name", + linked_sort_order: str = "asc", +) -> dict: payload = consultant_dashboard_payload(db, consultant=consultant) + payload["linked_clients_page"] = consultant_linked_clients_page( + db, + consultant=consultant, + q=linked_q, + client_group_id=linked_group_id, + status=linked_status, + page=linked_page, + per_page=linked_per_page, + sort_by=linked_sort_by, + sort_order=linked_sort_order, + ) try: payload["work_board"] = get_consultant_work_board(db, consultant=consultant) except Exception: @@ -1405,7 +1434,17 @@ def _build_consultant_portal_payload(db, consultant) -> dict: @portal_router.get("/dashboard") -def consultant_dashboard(request: Request, tab: str = "overview"): +def consultant_dashboard( + request: Request, + tab: str = "overview", + linked_q: str = "", + linked_group_id: int | None = None, + linked_status: str = "active", + linked_page: int = 1, + linked_per_page: int = 25, + linked_sort_by: str = "client_name", + linked_sort_order: str = "asc", +): db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1416,7 +1455,17 @@ def consultant_dashboard(request: Request, tab: str = "overview"): tenant_id = int(getattr(user, "tenant_id", 0) or 0) consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id) active_tab = tab if tab in CONSULTANT_PORTAL_TABS else "overview" - payload = _build_consultant_portal_payload(db, consultant) if consultant else _consultant_empty_payload() + payload = _build_consultant_portal_payload( + db, + consultant, + linked_q=linked_q, + linked_group_id=linked_group_id, + linked_status=linked_status, + linked_page=linked_page, + linked_per_page=linked_per_page, + linked_sort_by=linked_sort_by, + linked_sort_order=linked_sort_order, + ) if consultant else _consultant_empty_payload() return _render( request, "modules/consultants/templates/consultants/portal_dashboard.html", @@ -1432,7 +1481,17 @@ def consultant_dashboard(request: Request, tab: str = "overview"): @portal_router.get("/dashboard/tab/{tab_name}") -def consultant_dashboard_tab(request: Request, tab_name: str): +def consultant_dashboard_tab( + request: Request, + tab_name: str, + linked_q: str = "", + linked_group_id: int | None = None, + linked_status: str = "active", + linked_page: int = 1, + linked_per_page: int = 25, + linked_sort_by: str = "client_name", + linked_sort_order: str = "asc", +): db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1443,7 +1502,17 @@ def consultant_dashboard_tab(request: Request, tab_name: str): tenant_id = int(getattr(user, "tenant_id", 0) or 0) consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id) active_tab = tab_name if tab_name in CONSULTANT_PORTAL_TABS else "overview" - payload = _build_consultant_portal_payload(db, consultant) if consultant else _consultant_empty_payload() + payload = _build_consultant_portal_payload( + db, + consultant, + linked_q=linked_q, + linked_group_id=linked_group_id, + linked_status=linked_status, + linked_page=linked_page, + linked_per_page=linked_per_page, + linked_sort_by=linked_sort_by, + linked_sort_order=linked_sort_order, + ) if consultant else _consultant_empty_payload() return _render( request, CONSULTANT_PORTAL_TABS[active_tab],