Add compact searchable consultant client list

This commit is contained in:
A R R R Associates
2026-07-31 12:22:24 +05:30
parent d791670003
commit 07668318aa
4 changed files with 336 additions and 34 deletions
+116
View File
@@ -326,6 +326,122 @@ def list_client_links_for_consultant(db: Session, *, consultant_id: int) -> list
).scalars().all() ).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( def link_client_to_consultant(
db: Session, db: Session,
*, *,
@@ -38,6 +38,8 @@
{% include "modules/consultants/templates/consultants/portal_partials/clients_requests.html" %} {% include "modules/consultants/templates/consultants/portal_partials/clients_requests.html" %}
{% elif active_tab == 'documents-clarifications' %} {% elif active_tab == 'documents-clarifications' %}
{% include "modules/consultants/templates/consultants/portal_partials/documents_clarifications.html" %} {% include "modules/consultants/templates/consultants/portal_partials/documents_clarifications.html" %}
{% elif active_tab == 'reports' %}
{% include "modules/consultants/templates/consultants/portal_partials/reports.html" %}
{% else %} {% else %}
{% include "modules/consultants/templates/consultants/portal_partials/overview.html" %} {% include "modules/consultants/templates/consultants/portal_partials/overview.html" %}
{% endif %} {% endif %}
@@ -1,10 +1,148 @@
<div class="grid gap-6 xl:grid-cols-2"> {% set linked = payload.linked_clients_page %}
<div class="af-card"> {% set filters = linked.filters %}
{% set meta = linked.meta %}
<div class="space-y-6">
<section class="af-card">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-900">Linked Firm Clients</h3>
<p class="mt-1 text-sm text-slate-500">Firm clients explicitly linked to your consultant profile.</p>
</div>
<span class="w-fit rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{{ meta.total }} client{% if meta.total != 1 %}s{% endif %}</span>
</div>
<form method="get" action="/consultant/dashboard" class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<input type="hidden" name="tab" value="clients">
<div class="md:col-span-2 xl:col-span-2">
<label for="linked-client-search" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
<input id="linked-client-search" name="linked_q" value="{{ filters.q }}" placeholder="Client name, code, PAN, GSTIN or group"
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
</div>
<div>
<label for="linked-client-group" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Group</label>
<select id="linked-client-group" name="linked_group_id" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800">
<option value="">All groups</option>
{% for group in linked.groups %}
<option value="{{ group.id }}" {% if filters.client_group_id == group.id %}selected{% endif %}>{{ group.name }}{% if group.code %} ({{ group.code }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div>
<label for="linked-client-status" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Status</label>
<select id="linked-client-status" name="linked_status" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800">
<option value="active" {% if filters.status == 'active' %}selected{% endif %}>Active</option>
<option value="" {% if not filters.status %}selected{% endif %}>All statuses</option>
<option value="inactive" {% if filters.status == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
</div>
<div>
<label for="linked-client-sort" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sort</label>
<select id="linked-client-sort" name="linked_sort_by" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800">
<option value="client_name" {% if filters.sort_by == 'client_name' %}selected{% endif %}>Client name</option>
<option value="client_code" {% if filters.sort_by == 'client_code' %}selected{% endif %}>Client code</option>
<option value="pan" {% if filters.sort_by == 'pan' %}selected{% endif %}>PAN</option>
<option value="client_group" {% if filters.sort_by == 'client_group' %}selected{% endif %}>Client group</option>
<option value="status" {% if filters.sort_by == 'status' %}selected{% endif %}>Status</option>
</select>
</div>
<div class="flex items-end gap-2">
<input type="hidden" name="linked_sort_order" value="{{ filters.sort_order }}">
<input type="hidden" name="linked_per_page" value="{{ meta.per_page }}">
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Find</button>
<a href="/consultant/dashboard?tab=clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Clear</a>
</div>
</form>
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-left">
<thead class="bg-slate-50">
<tr class="text-xs font-semibold uppercase tracking-wide text-slate-500">
<th class="whitespace-nowrap px-4 py-3">Code</th>
<th class="min-w-[260px] px-4 py-3">Client Name</th>
<th class="whitespace-nowrap px-4 py-3">PAN</th>
<th class="min-w-[180px] px-4 py-3">Client Group</th>
<th class="whitespace-nowrap px-4 py-3">Role</th>
<th class="whitespace-nowrap px-4 py-3">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
{% for row in linked.rows %}
<tr class="hover:bg-slate-50">
<td class="whitespace-nowrap px-4 py-3 text-sm font-semibold text-slate-700">{{ row.client.client_code or '-' }}</td>
<td class="px-4 py-3">
<div class="font-semibold text-slate-900">{{ row.client.client_name }}</div>
{% if row.client.trade_name and row.client.trade_name != row.client.client_name %}<div class="text-xs text-slate-500">{{ row.client.trade_name }}</div>{% endif %}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-slate-700">{{ row.client.pan or '-' }}</td>
<td class="px-4 py-3 text-sm text-slate-700">
{% if row.client_group_name %}
<div class="font-medium">{{ row.client_group_name }}</div>
{% if row.client_group_code %}<div class="text-xs text-slate-500">{{ row.client_group_code }}</div>{% endif %}
{% else %}-{% endif %}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-slate-700">{{ row.relationship_label }}</td>
<td class="whitespace-nowrap px-4 py-3">
{% if row.client.status == 'active' %}
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</span>
{% else %}
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">{{ (row.client.status or 'inactive').replace('_',' ').title() }}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-10 text-center text-sm text-slate-500">No linked firm clients match the selected filters.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-slate-600">
{% if meta.total %}
Showing <span class="font-semibold text-slate-900">{{ ((meta.page - 1) * meta.per_page) + 1 }}</span> to
<span class="font-semibold text-slate-900">{{ meta.total if meta.page * meta.per_page > meta.total else meta.page * meta.per_page }}</span>
of <span class="font-semibold text-slate-900">{{ meta.total }}</span> clients
{% else %}No clients found{% endif %}
</div>
<div class="flex flex-wrap items-center gap-2">
<form method="get" action="/consultant/dashboard" class="flex items-center gap-2">
<input type="hidden" name="tab" value="clients">
<input type="hidden" name="linked_q" value="{{ filters.q }}">
{% if filters.client_group_id %}<input type="hidden" name="linked_group_id" value="{{ filters.client_group_id }}">{% endif %}
<input type="hidden" name="linked_status" value="{{ filters.status }}">
<input type="hidden" name="linked_sort_by" value="{{ filters.sort_by }}">
<input type="hidden" name="linked_sort_order" value="{{ filters.sort_order }}">
<input type="hidden" name="linked_page" value="1">
<label for="linked-per-page" class="text-sm text-slate-600">Rows</label>
<select id="linked-per-page" name="linked_per_page" onchange="this.form.submit()" class="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700">
{% for size in [25, 50, 100] %}<option value="{{ size }}" {% if meta.per_page == size %}selected{% endif %}>{{ size }}</option>{% endfor %}
</select>
</form>
{% 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 %}<a href="/consultant/dashboard?{{ query.value }}&linked_page={{ meta.page - 1 }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Previous</a>{% endif %}
{% for number in range(1, meta.pages + 1) %}
{% if number == meta.page %}
<span class="rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ number }}</span>
{% elif number == 1 or number == meta.pages or (number >= meta.page - 2 and number <= meta.page + 2) %}
<a href="/consultant/dashboard?{{ query.value }}&linked_page={{ number }}" class="rounded-lg px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">{{ number }}</a>
{% elif number == meta.page - 3 or number == meta.page + 3 %}<span class="px-1 text-slate-400"></span>{% endif %}
{% endfor %}
{% if meta.page < meta.pages %}<a href="/consultant/dashboard?{{ query.value }}&linked_page={{ meta.page + 1 }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Next</a>{% endif %}
</div>
</div>
</section>
<section class="af-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Managed Clients</h3><p class="mt-1 text-sm text-slate-500">Clients maintained by the consultant workspace.</p></div> <div>
<h3 class="text-lg font-semibold text-slate-900">Managed Clients</h3>
<p class="mt-1 text-sm text-slate-500">Clients maintained separately within the consultant workspace.</p>
</div>
<a href="/consultant/managed-clients" class="text-sm font-semibold text-brand-700 hover:underline">View all</a> <a href="/consultant/managed-clients" class="text-sm font-semibold text-brand-700 hover:underline">View all</a>
</div> </div>
<div class="mt-4 space-y-3"> <div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{% for client in payload.managed_clients %} {% for client in payload.managed_clients %}
<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"> <a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ client.client_name }}</div> <div class="font-semibold text-slate-900">{{ client.client_name }}</div>
@@ -12,31 +150,8 @@
<div class="mt-2 text-xs text-slate-600">Conversion: {{ client.conversion_status.replace('_',' ').title() if client.conversion_status else 'Not Requested' }}</div> <div class="mt-2 text-xs text-slate-600">Conversion: {{ client.conversion_status.replace('_',' ').title() if client.conversion_status else 'Not Requested' }}</div>
</a> </a>
{% else %} {% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No managed clients yet.</div> <div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 md:col-span-2 xl:col-span-3">No managed clients yet.</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </section>
<div class="af-card">
<div class="flex items-center justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Linked Firm Clients</h3><p class="mt-1 text-sm text-slate-500">Only explicitly linked firm clients are visible.</p></div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{{ payload.active_links|length }} active</span>
</div>
<div class="mt-4 space-y-3">
{% for link in payload.active_links %}
<div class="rounded-xl border border-slate-200 p-3">
<div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div>
<div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Client {{ 'visible' if link.can_view_client else 'hidden' }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Services {{ 'visible' if link.can_view_services else 'hidden' }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Due dates {{ 'visible' if link.can_view_due_dates else 'hidden' }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Communications {{ 'visible' if link.can_view_communications else 'hidden' }}</span>
</div>
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No firm clients are linked yet.</div>
{% endfor %}
</div>
</div>
</div> </div>
+74 -5
View File
@@ -27,6 +27,7 @@ from app.modules.consultants.service import (
approve_managed_client_conversion, approve_managed_client_conversion,
consultant_can_add_managed_client, consultant_can_add_managed_client,
consultant_dashboard_payload, consultant_dashboard_payload,
consultant_linked_clients_page,
create_consultant_service_request, create_consultant_service_request,
create_or_update_consultant, create_or_update_consultant,
create_or_update_managed_client, create_or_update_managed_client,
@@ -1349,6 +1350,12 @@ def _consultant_empty_payload() -> dict:
return { return {
"links": [], "links": [],
"active_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": [], "comments": [],
"recent_firm_messages": [], "recent_firm_messages": [],
"recent_consultant_replies": [], "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 = 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: try:
payload["work_board"] = get_consultant_work_board(db, consultant=consultant) payload["work_board"] = get_consultant_work_board(db, consultant=consultant)
except Exception: except Exception:
@@ -1405,7 +1434,17 @@ def _build_consultant_portal_payload(db, consultant) -> dict:
@portal_router.get("/dashboard") @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() db = CommonSessionLocal()
try: try:
user = get_current_user(request, db=db) 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) tenant_id = int(getattr(user, "tenant_id", 0) or 0)
consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id) 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" 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( return _render(
request, request,
"modules/consultants/templates/consultants/portal_dashboard.html", "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}") @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() db = CommonSessionLocal()
try: try:
user = get_current_user(request, db=db) 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) tenant_id = int(getattr(user, "tenant_id", 0) or 0)
consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id) 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" 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( return _render(
request, request,
CONSULTANT_PORTAL_TABS[active_tab], CONSULTANT_PORTAL_TABS[active_tab],