Add instant consultant client filtering without pagination

This commit is contained in:
A R R R Associates
2026-07-31 13:34:24 +05:30
parent 9c2708d671
commit a40f9af975
2 changed files with 130 additions and 127 deletions
+16 -56
View File
@@ -1,13 +1,11 @@
from __future__ import annotations from __future__ import annotations
from math import ceil
from datetime import date, datetime, timezone, timedelta from datetime import date, datetime, timezone, timedelta
import secrets import secrets
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client
from app.modules.clients.models import Client, ClientAuditLog from app.modules.clients.models import Client, ClientAuditLog
from app.modules.client_groups.models import ClientGroup from app.modules.client_groups.models import ClientGroup
from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest
@@ -338,20 +336,16 @@ def consultant_linked_clients_page(
status: str = "active", status: str = "active",
page: int = 1, page: int = 1,
per_page: int = 25, per_page: int = 25,
sort_by: str = "client_name", sort_by: str = "client_code",
sort_order: str = "asc", sort_order: str = "asc",
) -> dict: ) -> dict:
"""Return a compact, searchable and paginated list of linked firm clients. """Return every active consultant-linked firm client for instant browser filtering.
Link permission columns remain unchanged and continue to govern the existing The existing link permission fields and all downstream service, due-date,
service, due-date, communication and document workflows. They are deliberately communication and document controls remain unchanged. Search, group, status
not exposed in the client-list UI. and sorting are performed instantly in the rendered client table, so no
pagination or form submission is required.
""" """
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 = ( stmt = (
select(ClientConsultantLink, Client, ClientGroup) select(ClientConsultantLink, Client, ClientGroup)
.join(Client, Client.id == ClientConsultantLink.client_id) .join(Client, Client.id == ClientConsultantLink.client_id)
@@ -363,53 +357,17 @@ def consultant_linked_clients_page(
Client.tenant_id == consultant.tenant_id, Client.tenant_id == consultant.tenant_id,
Client.is_archived.is_(False), Client.is_archived.is_(False),
) )
.order_by(Client.client_code.asc(), Client.client_name.asc(), Client.id.asc())
) )
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 = [] rows = []
result = db.execute( for link, client, group in db.execute(stmt).all():
stmt.order_by(ordering, Client.id.asc())
.offset((page - 1) * per_page)
.limit(per_page)
).all()
for link, client, group in result:
rows.append({ rows.append({
"link": link, "link": link,
"client": client, "client": client,
"client_group_id": getattr(group, "id", None), "client_group_id": getattr(group, "id", None),
"client_group_code": getattr(group, "group_code", None), "client_group_code": getattr(group, "group_code", None),
"client_group_name": getattr(group, "group_name", None), "client_group_name": getattr(group, "group_name", None),
"relationship_label": (link.relationship_type or "accounts_consultant").replace("_", " ").title(),
}) })
groups_stmt = ( groups_stmt = (
@@ -431,16 +389,18 @@ def consultant_linked_clients_page(
{"id": int(group_id), "code": code, "name": name} {"id": int(group_id), "code": code, "name": name}
for group_id, code, name in db.execute(groups_stmt).all() for group_id, code, name in db.execute(groups_stmt).all()
] ]
total = len(rows)
return { return {
"rows": rows, "rows": rows,
"groups": groups, "groups": groups,
"meta": {"total": total, "page": page, "per_page": per_page, "pages": pages}, "meta": {"total": total, "page": 1, "per_page": total, "pages": 1},
"filters": { "filters": {
"q": q, "q": "",
"client_group_id": client_group_id, "client_group_id": None,
"status": status, "status": "active",
"sort_by": sort_by if sort_by in sort_columns else "client_name", "sort_by": "client_code",
"sort_order": "desc" if (sort_order or "asc").lower() == "desc" else "asc", "sort_order": "asc",
}, },
} }
@@ -1,74 +1,77 @@
{% set linked = payload.linked_clients_page %} {% set linked = payload.linked_clients_page %}
{% set filters = linked.filters %}
{% set meta = linked.meta %} {% set meta = linked.meta %}
<div class="space-y-6"> <div class="space-y-6">
<section class="af-card"> <section class="af-card" id="linked-firm-clients-section">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h3 class="text-lg font-semibold text-slate-900">Linked Firm Clients</h3> <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> <p class="mt-1 text-sm text-slate-500">Firm clients explicitly linked to your consultant profile.</p>
</div> </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> <span id="linked-client-count" 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> </div>
<form method="get" action="/consultant/dashboard" class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-6"> <div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<input type="hidden" name="tab" value="clients">
<div class="md:col-span-2 xl:col-span-2"> <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> <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" <input id="linked-client-search" type="search" autocomplete="off"
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"> 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>
<div> <div>
<label for="linked-client-group" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Group</label> <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"> <select id="linked-client-group" 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> <option value="">All groups</option>
{% for group in linked.groups %} {% 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> <option value="{{ group.id }}">{{ group.name }}{% if group.code %} ({{ group.code }}){% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div> <div>
<label for="linked-client-status" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Status</label> <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"> <select id="linked-client-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="active" selected>Active</option>
<option value="" {% if not filters.status %}selected{% endif %}>All statuses</option> <option value="">All statuses</option>
<option value="inactive" {% if filters.status == 'inactive' %}selected{% endif %}>Inactive</option> <option value="inactive">Inactive</option>
</select> </select>
</div> </div>
<div> <div>
<label for="linked-client-sort" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sort</label> <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"> <select id="linked-client-sort" 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" selected>Client code</option>
<option value="client_code" {% if filters.sort_by == 'client_code' %}selected{% endif %}>Client code</option> <option value="client_name">Client name</option>
<option value="pan" {% if filters.sort_by == 'pan' %}selected{% endif %}>PAN</option> <option value="pan">PAN</option>
<option value="client_group" {% if filters.sort_by == 'client_group' %}selected{% endif %}>Client group</option> <option value="client_group">Client group</option>
<option value="status" {% if filters.sort_by == 'status' %}selected{% endif %}>Status</option> <option value="status">Status</option>
</select> </select>
</div> </div>
<div class="flex items-end gap-2"> </div>
<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"> <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"> <table class="min-w-full divide-y divide-slate-200 text-left">
<thead class="bg-slate-50"> <thead class="bg-slate-50">
<tr class="text-xs font-semibold uppercase tracking-wide text-slate-500"> <tr class="text-xs font-semibold uppercase tracking-wide text-slate-500">
<th class="whitespace-nowrap px-4 py-3">Code</th> <th class="whitespace-nowrap px-4 py-3">Code</th>
<th class="min-w-[260px] px-4 py-3">Client Name</th> <th class="min-w-[300px] px-4 py-3">Client Name</th>
<th class="whitespace-nowrap px-4 py-3">PAN</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="min-w-[190px] 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> <th class="whitespace-nowrap px-4 py-3">Status</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100 bg-white"> <tbody id="linked-client-table-body" class="divide-y divide-slate-100 bg-white">
{% for row in linked.rows %} {% for row in linked.rows %}
<tr class="hover:bg-slate-50"> {% set client_status = (row.client.status or 'inactive')|lower %}
<tr class="linked-client-row hover:bg-slate-50"
data-code="{{ (row.client.client_code or '')|lower|e }}"
data-name="{{ (row.client.client_name or '')|lower|e }}"
data-trade-name="{{ (row.client.trade_name or '')|lower|e }}"
data-pan="{{ (row.client.pan or '')|lower|e }}"
data-gstin="{{ (row.client.gstin or '')|lower|e }}"
data-group-id="{{ row.client_group_id or '' }}"
data-group="{{ (row.client_group_name or '')|lower|e }} {{ (row.client_group_code or '')|lower|e }}"
data-status="{{ client_status|e }}">
<td class="whitespace-nowrap px-4 py-3 text-sm font-semibold text-slate-700">{{ row.client.client_code or '-' }}</td> <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"> <td class="px-4 py-3">
<div class="font-semibold text-slate-900">{{ row.client.client_name }}</div> <div class="font-semibold text-slate-900">{{ row.client.client_name }}</div>
@@ -81,57 +84,21 @@
{% if row.client_group_code %}<div class="text-xs text-slate-500">{{ row.client_group_code }}</div>{% endif %} {% if row.client_group_code %}<div class="text-xs text-slate-500">{{ row.client_group_code }}</div>{% endif %}
{% else %}-{% endif %} {% else %}-{% endif %}
</td> </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"> <td class="whitespace-nowrap px-4 py-3">
{% if row.client.status == 'active' %} {% if client_status == 'active' %}
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</span> <span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</span>
{% else %} {% 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> <span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">{{ client_status.replace('_',' ').title() }}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </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 %} {% endfor %}
<tr id="linked-client-empty-row" class="hidden">
<td colspan="5" class="px-4 py-10 text-center text-sm text-slate-500">No linked firm clients match the selected filters.</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </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>
<section class="af-card"> <section class="af-card">
@@ -155,3 +122,79 @@
</div> </div>
</section> </section>
</div> </div>
<script>
(() => {
const section = document.getElementById('linked-firm-clients-section');
if (!section || section.dataset.liveFilterReady === 'true') return;
section.dataset.liveFilterReady = 'true';
const search = document.getElementById('linked-client-search');
const group = document.getElementById('linked-client-group');
const status = document.getElementById('linked-client-status');
const sort = document.getElementById('linked-client-sort');
const body = document.getElementById('linked-client-table-body');
const count = document.getElementById('linked-client-count');
const empty = document.getElementById('linked-client-empty-row');
const rows = Array.from(body.querySelectorAll('.linked-client-row'));
let timer = null;
const normalise = (value) => (value || '').toString().trim().toLocaleLowerCase();
function applyFilters() {
const query = normalise(search.value);
const selectedGroup = group.value;
const selectedStatus = normalise(status.value);
const sortKey = sort.value;
const visible = rows.filter((row) => {
const searchable = [
row.dataset.code,
row.dataset.name,
row.dataset.tradeName,
row.dataset.pan,
row.dataset.gstin,
row.dataset.group
].join(' ');
const matchesSearch = !query || searchable.includes(query);
const matchesGroup = !selectedGroup || row.dataset.groupId === selectedGroup;
const matchesStatus = !selectedStatus || row.dataset.status === selectedStatus;
return matchesSearch && matchesGroup && matchesStatus;
});
visible.sort((a, b) => {
const keyMap = {
client_code: 'code',
client_name: 'name',
pan: 'pan',
client_group: 'group',
status: 'status'
};
const dataKey = keyMap[sortKey] || 'code';
return normalise(a.dataset[dataKey]).localeCompare(normalise(b.dataset[dataKey]), undefined, {
numeric: true,
sensitivity: 'base'
});
});
rows.forEach((row) => row.classList.add('hidden'));
visible.forEach((row) => {
row.classList.remove('hidden');
body.insertBefore(row, empty);
});
empty.classList.toggle('hidden', visible.length !== 0);
count.textContent = `${visible.length} client${visible.length === 1 ? '' : 's'}`;
}
search.addEventListener('input', () => {
window.clearTimeout(timer);
timer = window.setTimeout(applyFilters, 250);
});
group.addEventListener('change', applyFilters);
status.addEventListener('change', applyFilters);
sort.addEventListener('change', applyFilters);
applyFilters();
})();
</script>