Add unified hierarchical client management interface
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.clients.access import (
|
||||
build_scope,
|
||||
can_view_client_row,
|
||||
enforce_partner_scope,
|
||||
is_partner_role,
|
||||
)
|
||||
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
|
||||
from app.modules.clients.service import list_clients_payload
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.registrations.models import ClientRegistration, RegistrationType
|
||||
|
||||
router = APIRouter(prefix="/clients", tags=["unified-client-management"])
|
||||
|
||||
|
||||
def _has_perm_factory(db, user):
|
||||
permissions = set(get_user_permissions(db, user.id))
|
||||
return lambda code: code in permissions
|
||||
|
||||
|
||||
def _role_names(db, user) -> set[str]:
|
||||
return {str(role).strip().lower() for role in get_user_roles(db, user.id)}
|
||||
|
||||
|
||||
def _apply_role_scope(scope, user, role_names: set[str]):
|
||||
if "system admin" in role_names:
|
||||
scope.allow_all_clients = True
|
||||
scope.allow_cross_tenant = True
|
||||
scope.allow_cross_branch = True
|
||||
scope.own_only = False
|
||||
scope.locked_partner_id = None
|
||||
return enforce_partner_scope(scope, user=user, role_names=role_names)
|
||||
|
||||
|
||||
def _viewer_partner_id(user, role_names: set[str]):
|
||||
return int(user.id) if is_partner_role(role_names) else None
|
||||
|
||||
|
||||
def _norm(value) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _matches(needle: str, *values) -> bool:
|
||||
if not needle:
|
||||
return True
|
||||
return any(needle in _norm(value) for value in values)
|
||||
|
||||
|
||||
@router.get("/management")
|
||||
def unified_client_management(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
registration_type: str = "",
|
||||
registration_status: str = "",
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
permissions = set(get_user_permissions(db, user.id))
|
||||
if "clients.view" not in permissions:
|
||||
return RedirectResponse("/dashboard", 303)
|
||||
|
||||
roles = _role_names(db, user)
|
||||
# This management console is intentionally exposed to the two requested
|
||||
# operational roles. System Admin retains access for support/admin use.
|
||||
if not ({"partner", "firm admin", "system admin"} & roles):
|
||||
return RedirectResponse("/clients", 303)
|
||||
|
||||
has = _has_perm_factory(db, user)
|
||||
scope = _apply_role_scope(build_scope(request, user, has), user, roles)
|
||||
|
||||
# Reuse the production client-list scope engine rather than inventing a
|
||||
# second visibility rule. Fetch a broad scoped set, then enrich it.
|
||||
base = list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
allow_all_clients=scope.allow_all_clients,
|
||||
partner_id=None,
|
||||
viewer_partner_id=_viewer_partner_id(user, roles),
|
||||
q="",
|
||||
status="",
|
||||
client_type="",
|
||||
client_group_id=None,
|
||||
include_archived=True,
|
||||
page=1,
|
||||
per_page=10000,
|
||||
sort_by="client_name",
|
||||
sort_order="asc",
|
||||
)
|
||||
visible_rows = [
|
||||
row for row in base.get("rows", [])
|
||||
if can_view_client_row(scope, row, user_id=user.id)
|
||||
]
|
||||
visible_ids = [int(row["id"]) for row in visible_rows if row.get("id") is not None]
|
||||
row_meta = {int(row["id"]): row for row in visible_rows if row.get("id") is not None}
|
||||
|
||||
if not visible_ids:
|
||||
clients = []
|
||||
else:
|
||||
clients = db.execute(
|
||||
select(Client)
|
||||
.where(Client.id.in_(visible_ids))
|
||||
.order_by(Client.client_name.asc(), Client.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
businesses = []
|
||||
branches = []
|
||||
registration_pairs = []
|
||||
if visible_ids:
|
||||
businesses = db.execute(
|
||||
select(ClientBusinessUnit)
|
||||
.where(ClientBusinessUnit.client_id.in_(visible_ids))
|
||||
.order_by(
|
||||
ClientBusinessUnit.client_id,
|
||||
ClientBusinessUnit.is_active.desc(),
|
||||
ClientBusinessUnit.is_primary.desc(),
|
||||
ClientBusinessUnit.business_name.asc(),
|
||||
)
|
||||
).scalars().all()
|
||||
branches = db.execute(
|
||||
select(ClientBranch)
|
||||
.where(ClientBranch.client_id.in_(visible_ids))
|
||||
.order_by(
|
||||
ClientBranch.client_id,
|
||||
ClientBranch.is_active.desc(),
|
||||
ClientBranch.is_primary.desc(),
|
||||
ClientBranch.branch_name.asc(),
|
||||
)
|
||||
).scalars().all()
|
||||
registration_pairs = db.execute(
|
||||
select(ClientRegistration, RegistrationType)
|
||||
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
|
||||
.where(ClientRegistration.client_id.in_(visible_ids))
|
||||
.order_by(
|
||||
ClientRegistration.client_id,
|
||||
RegistrationType.sort_order.asc(),
|
||||
RegistrationType.code.asc(),
|
||||
ClientRegistration.registration_number.asc(),
|
||||
)
|
||||
).all()
|
||||
|
||||
businesses_by_client: dict[int, list] = {}
|
||||
businesses_by_id: dict[int, ClientBusinessUnit] = {}
|
||||
for row in businesses:
|
||||
businesses_by_client.setdefault(row.client_id, []).append(row)
|
||||
businesses_by_id[row.id] = row
|
||||
|
||||
branches_by_client: dict[int, list] = {}
|
||||
branches_by_business: dict[int, list] = {}
|
||||
branches_by_id: dict[int, ClientBranch] = {}
|
||||
for row in branches:
|
||||
branches_by_client.setdefault(row.client_id, []).append(row)
|
||||
branches_by_business.setdefault(row.business_unit_id, []).append(row)
|
||||
branches_by_id[row.id] = row
|
||||
|
||||
regs_by_client: dict[int, list[tuple[ClientRegistration, RegistrationType]]] = {}
|
||||
regs_by_business: dict[int, list[tuple[ClientRegistration, RegistrationType]]] = {}
|
||||
regs_by_branch: dict[int, list[tuple[ClientRegistration, RegistrationType]]] = {}
|
||||
for reg, reg_type in registration_pairs:
|
||||
pair = (reg, reg_type)
|
||||
regs_by_client.setdefault(reg.client_id, []).append(pair)
|
||||
if reg.business_unit_id:
|
||||
regs_by_business.setdefault(reg.business_unit_id, []).append(pair)
|
||||
if reg.client_branch_id:
|
||||
regs_by_branch.setdefault(reg.client_branch_id, []).append(pair)
|
||||
|
||||
needle = _norm(q)
|
||||
reg_type_filter = _norm(registration_type)
|
||||
reg_status_filter = _norm(registration_status)
|
||||
client_status_filter = _norm(status)
|
||||
client_type_filter = _norm(client_type)
|
||||
|
||||
filtered_clients = []
|
||||
for client in clients:
|
||||
if client_status_filter and _norm(client.status) != client_status_filter:
|
||||
continue
|
||||
if client_type_filter and _norm(client.client_type) != client_type_filter:
|
||||
continue
|
||||
|
||||
client_businesses = businesses_by_client.get(client.id, [])
|
||||
client_branches = branches_by_client.get(client.id, [])
|
||||
client_regs = regs_by_client.get(client.id, [])
|
||||
|
||||
if reg_type_filter and not any(_norm(rt.code) == reg_type_filter for _, rt in client_regs):
|
||||
continue
|
||||
if reg_status_filter and not any(_norm(reg.status) == reg_status_filter for reg, _ in client_regs):
|
||||
continue
|
||||
|
||||
if needle:
|
||||
hit = _matches(
|
||||
needle,
|
||||
client.client_name, client.trade_name, client.client_code,
|
||||
client.pan, client.gstin, client.tan, client.cin_llpin,
|
||||
client.mobile, client.email, client.city, client.state,
|
||||
)
|
||||
if not hit:
|
||||
hit = any(_matches(
|
||||
needle, b.business_name, b.trade_name, b.business_code, b.nature_of_business
|
||||
) for b in client_businesses)
|
||||
if not hit:
|
||||
hit = any(_matches(
|
||||
needle, br.branch_name, br.branch_code, br.city, br.state, br.pincode
|
||||
) for br in client_branches)
|
||||
if not hit:
|
||||
hit = any(_matches(
|
||||
needle, reg.registration_number, reg.legal_name, reg.trade_name,
|
||||
reg.state, reg.jurisdiction, rt.code, rt.name
|
||||
) for reg, rt in client_regs)
|
||||
if not hit:
|
||||
continue
|
||||
filtered_clients.append(client)
|
||||
|
||||
per_page = per_page if per_page in {10, 25, 50, 100} else 25
|
||||
total = len(filtered_clients)
|
||||
pages = max(1, ceil(total / per_page))
|
||||
page = max(1, min(int(page or 1), pages))
|
||||
start = (page - 1) * per_page
|
||||
page_clients = filtered_clients[start:start + per_page]
|
||||
|
||||
hierarchy = []
|
||||
for client in page_clients:
|
||||
business_nodes = []
|
||||
assigned_reg_ids: set[int] = set()
|
||||
for business in businesses_by_client.get(client.id, []):
|
||||
branch_nodes = []
|
||||
for branch in branches_by_business.get(business.id, []):
|
||||
branch_regs = regs_by_branch.get(branch.id, [])
|
||||
assigned_reg_ids.update(reg.id for reg, _ in branch_regs)
|
||||
branch_nodes.append({
|
||||
"row": branch,
|
||||
"registrations": branch_regs,
|
||||
})
|
||||
direct_business_regs = [
|
||||
pair for pair in regs_by_business.get(business.id, [])
|
||||
if not pair[0].client_branch_id
|
||||
]
|
||||
assigned_reg_ids.update(reg.id for reg, _ in direct_business_regs)
|
||||
business_nodes.append({
|
||||
"row": business,
|
||||
"branches": branch_nodes,
|
||||
"registrations": direct_business_regs,
|
||||
})
|
||||
|
||||
unassigned_regs = [
|
||||
pair for pair in regs_by_client.get(client.id, [])
|
||||
if pair[0].id not in assigned_reg_ids
|
||||
]
|
||||
hierarchy.append({
|
||||
"client": client,
|
||||
"meta": row_meta.get(client.id, {}),
|
||||
"businesses": business_nodes,
|
||||
"unassigned_registrations": unassigned_regs,
|
||||
"business_count": len(businesses_by_client.get(client.id, [])),
|
||||
"branch_count": len(branches_by_client.get(client.id, [])),
|
||||
"registration_count": len(regs_by_client.get(client.id, [])),
|
||||
})
|
||||
|
||||
all_visible_client_ids = {c.id for c in filtered_clients}
|
||||
filtered_businesses = [b for b in businesses if b.client_id in all_visible_client_ids]
|
||||
filtered_branches = [b for b in branches if b.client_id in all_visible_client_ids]
|
||||
filtered_regs = [(r, t) for r, t in registration_pairs if r.client_id in all_visible_client_ids]
|
||||
|
||||
counters = {
|
||||
"clients": len(filtered_clients),
|
||||
"businesses": len(filtered_businesses),
|
||||
"branches": len(filtered_branches),
|
||||
"registrations": len(filtered_regs),
|
||||
"gstin": sum(1 for _, rt in filtered_regs if _norm(rt.code) == "gstin"),
|
||||
"tan": sum(1 for _, rt in filtered_regs if _norm(rt.code) == "tan"),
|
||||
"active_registrations": sum(
|
||||
1 for reg, _ in filtered_regs
|
||||
if _norm(reg.status) in {"active", "valid", "registered"}
|
||||
),
|
||||
}
|
||||
|
||||
registration_types = db.execute(
|
||||
select(RegistrationType)
|
||||
.where(RegistrationType.is_active.is_(True))
|
||||
.order_by(RegistrationType.sort_order.asc(), RegistrationType.name.asc())
|
||||
).scalars().all()
|
||||
|
||||
client_types = sorted({str(c.client_type) for c in clients if c.client_type})
|
||||
registration_statuses = sorted({
|
||||
str(reg.status) for reg, _ in registration_pairs if reg.status
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/clients/templates/clients/management.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": list(permissions),
|
||||
"title": "Client Management",
|
||||
"hierarchy": hierarchy,
|
||||
"counters": counters,
|
||||
"q": q,
|
||||
"status": status,
|
||||
"client_type": client_type,
|
||||
"registration_type": registration_type,
|
||||
"registration_status": registration_status,
|
||||
"registration_types": registration_types,
|
||||
"registration_statuses": registration_statuses,
|
||||
"client_types": client_types,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"can_create": "clients.create" in permissions,
|
||||
"can_edit": "clients.edit" in permissions,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,216 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Clients · Unified Management</div>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Client Management</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Compact Client → Business → Branch → Registration management using the existing ERP masters and permissions.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_create %}<a href="/clients/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Client</a>{% endif %}
|
||||
<a href="/clients" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Existing Client List</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
|
||||
{% for label, value in [
|
||||
('Clients', counters.clients), ('Businesses', counters.businesses), ('Branches', counters.branches),
|
||||
('Registrations', counters.registrations), ('GSTINs', counters.gstin), ('TANs', counters.tan)
|
||||
] %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ value }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="get" action="/clients/management" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<div class="xl:col-span-2">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-slate-500">Search</label>
|
||||
<input name="q" value="{{ q }}" placeholder="Client, trade name, PAN, GSTIN, TAN, branch, email..." class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-slate-500">Client status</label>
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
{% for value in ['active','inactive','archived'] %}<option value="{{ value }}" {% if status|lower == value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-slate-500">Client type</label>
|
||||
<select name="client_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
{% for value in client_types %}<option value="{{ value }}" {% if client_type == value %}selected{% endif %}>{{ value }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-slate-500">Registration</label>
|
||||
<select name="registration_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
{% for rt in registration_types %}<option value="{{ rt.code }}" {% if registration_type|lower == rt.code|lower %}selected{% endif %}>{{ rt.code }} · {{ rt.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-slate-500">Reg. status</label>
|
||||
<select name="registration_status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
{% for value in registration_statuses %}<option value="{{ value }}" {% if registration_status == value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-xs text-slate-500">Search covers client name/code, business/trade name, PAN, GSTIN, TAN, CIN/LLPIN, branch, mobile and email.</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/clients/management" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700">Clear</a>
|
||||
<button class="rounded-xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ total }} client{{ '' if total == 1 else 's' }}</div>
|
||||
<div class="text-xs text-slate-500">Expand only the client you need. Existing detail/edit/business-structure pages remain available.</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-slate-500">Page {{ page }} of {{ pages }}</div>
|
||||
</div>
|
||||
|
||||
{% for node in hierarchy %}
|
||||
{% set client = node.client %}
|
||||
<details class="group border-b border-slate-100 last:border-b-0">
|
||||
<summary class="cursor-pointer list-none px-4 py-4 hover:bg-slate-50">
|
||||
<div class="grid items-center gap-3 lg:grid-cols-[minmax(280px,1.7fr)_140px_110px_120px_210px]">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-700 transition group-open:rotate-90">›</span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-semibold text-blue-700">{{ client.client_name }}</div>
|
||||
<div class="mt-0.5 flex flex-wrap gap-x-2 text-xs text-slate-500">
|
||||
<span>{{ client.client_code }}</span>
|
||||
{% if client.trade_name and client.trade_name != client.client_name %}<span>· {{ client.trade_name }}</span>{% endif %}
|
||||
{% if client.pan %}<span>· PAN {{ client.pan }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">{{ client.client_type }}</span></div>
|
||||
<div class="text-sm"><span class="font-semibold">{{ node.business_count }}</span><span class="ml-1 text-xs text-slate-500">business</span></div>
|
||||
<div class="text-sm"><span class="font-semibold">{{ node.branch_count }}</span><span class="ml-1 text-xs text-slate-500">branch</span></div>
|
||||
<div class="flex justify-end gap-2" onclick="event.stopPropagation()">
|
||||
<a href="/clients/{{ client.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700">View</a>
|
||||
<a href="/clients/{{ client.id }}/business-structure" class="rounded-lg border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-700">Manage Structure</a>
|
||||
{% if can_edit %}<a href="/clients/{{ client.id }}/edit" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="border-t border-slate-100 bg-slate-50/70 px-4 py-4">
|
||||
{% if node.businesses %}
|
||||
<div class="space-y-3">
|
||||
{% for business_node in node.businesses %}
|
||||
{% set business = business_node.row %}
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div class="flex flex-col gap-2 border-b border-slate-100 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-slate-900">{{ business.business_name }}</span>
|
||||
{% if business.is_primary %}<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Primary Business</span>{% endif %}
|
||||
{% if not business.is_active %}<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">Inactive</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-slate-500">{{ business.business_code }}{% if business.trade_name %} · Trade: {{ business.trade_name }}{% endif %}{% if business.nature_of_business %} · {{ business.nature_of_business }}{% endif %}</div>
|
||||
</div>
|
||||
<a href="/clients/{{ client.id }}/business-structure" class="text-xs font-semibold text-brand-700">Manage business</a>
|
||||
</div>
|
||||
|
||||
{% for branch_node in business_node.branches %}
|
||||
{% set branch = branch_node.row %}
|
||||
<div class="border-b border-slate-100 px-5 py-3 last:border-b-0">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-slate-800">↳ {{ branch.branch_name }}</span>
|
||||
<span class="ml-2 text-xs text-slate-500">{{ branch.branch_code }}{% if branch.city %} · {{ branch.city }}{% endif %}{% if branch.state %}, {{ branch.state }}{% endif %}</span>
|
||||
{% if branch.is_primary %}<span class="ml-2 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-semibold text-blue-700">Primary Branch</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ branch_node.registrations|length }} registration{{ '' if branch_node.registrations|length == 1 else 's' }}</div>
|
||||
</div>
|
||||
{% if branch_node.registrations %}
|
||||
<div class="mt-2 grid gap-2 xl:grid-cols-2">
|
||||
{% for reg, rt in branch_node.registrations %}
|
||||
<div class="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-md bg-white px-2 py-0.5 text-[11px] font-bold text-slate-700 ring-1 ring-slate-200">{{ rt.code }}</span>
|
||||
<span class="truncate text-sm font-medium text-slate-800">{{ reg.registration_number }}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold {% if reg.status|lower in ['active','valid','registered'] %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ reg.status|title }}</span>
|
||||
</div>
|
||||
{% if reg.trade_name or reg.legal_name %}<div class="mt-0.5 truncate text-xs text-slate-500">{{ reg.trade_name or reg.legal_name }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if business_node.registrations %}
|
||||
<div class="px-5 py-3">
|
||||
<div class="mb-2 text-xs font-semibold uppercase text-slate-500">Business-level registrations</div>
|
||||
<div class="grid gap-2 xl:grid-cols-2">
|
||||
{% for reg, rt in business_node.registrations %}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="mr-2 rounded-md bg-white px-2 py-0.5 text-[11px] font-bold ring-1 ring-slate-200">{{ rt.code }}</span>
|
||||
<span class="font-medium">{{ reg.registration_number }}</span>
|
||||
<span class="ml-2 text-xs text-slate-500">{{ reg.trade_name or reg.legal_name or '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">
|
||||
No Business/Business Unit has been created for this client.
|
||||
{% if can_edit %}<a href="/clients/{{ client.id }}/business-structure" class="ml-1 font-semibold text-brand-700">Create business structure</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if node.unassigned_registrations %}
|
||||
<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50/60 p-4">
|
||||
<div class="text-xs font-semibold uppercase text-amber-800">Client-level / unassigned registrations</div>
|
||||
<div class="mt-2 grid gap-2 xl:grid-cols-2">
|
||||
{% for reg, rt in node.unassigned_registrations %}
|
||||
<div class="rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm">
|
||||
<span class="mr-2 rounded-md bg-amber-50 px-2 py-0.5 text-[11px] font-bold text-amber-800">{{ rt.code }}</span>
|
||||
<span class="font-medium">{{ reg.registration_number }}</span>
|
||||
<span class="ml-2 text-xs text-slate-500">{{ reg.trade_name or reg.legal_name or '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="px-6 py-16 text-center">
|
||||
<div class="text-base font-semibold text-slate-700">No clients match the selected filters.</div>
|
||||
<div class="mt-1 text-sm text-slate-500">Clear one or more filters and try again.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-slate-500">Showing {{ ((page - 1) * per_page) + 1 if total else 0 }}–{{ [page * per_page, total]|min }} of {{ total }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% set qs = 'q=' ~ (q|urlencode) ~ '&status=' ~ (status|urlencode) ~ '&client_type=' ~ (client_type|urlencode) ~ '®istration_type=' ~ (registration_type|urlencode) ~ '®istration_status=' ~ (registration_status|urlencode) ~ '&per_page=' ~ per_page %}
|
||||
{% if page > 1 %}<a href="/clients/management?{{ qs }}&page={{ page - 1 }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm">Previous</a>{% endif %}
|
||||
<span class="px-2 text-sm font-medium text-slate-700">{{ page }} / {{ pages }}</span>
|
||||
{% if page < pages %}<a href="/clients/management?{{ qs }}&page={{ page + 1 }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm">Next</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user