Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from .api import router as api_router
|
||||
from .ui import router as ui_router
|
||||
|
||||
__all__ = ["api_router", "ui_router"]
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientAccessScope:
|
||||
tenant_id: int
|
||||
branch_id: int | None
|
||||
allow_cross_branch: bool
|
||||
allow_cross_tenant: bool
|
||||
allow_all_clients: bool
|
||||
own_only: bool
|
||||
locked_partner_id: int | None
|
||||
can_assign_partner: bool
|
||||
can_change_branch: bool
|
||||
can_change_tenant: bool
|
||||
|
||||
|
||||
def build_scope(request, user, permission_checker):
|
||||
active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id)
|
||||
active_branch_value = request.session.get("active_branch_id")
|
||||
active_branch_id = None if active_branch_value in (None, "", 0, "0") else int(active_branch_value)
|
||||
|
||||
allow_cross_branch = permission_checker("clients.cross_branch")
|
||||
allow_cross_tenant = permission_checker("clients.cross_tenant")
|
||||
can_assign_partner = permission_checker("clients.assign_partner")
|
||||
own_only = permission_checker("clients.view.own_only")
|
||||
|
||||
allow_all_clients = bool((allow_cross_tenant and allow_cross_branch and not own_only) or permission_checker("clients.view.all"))
|
||||
|
||||
locked_partner_id = user.id if own_only else None
|
||||
can_change_branch = allow_cross_branch
|
||||
can_change_tenant = allow_cross_tenant
|
||||
|
||||
return ClientAccessScope(
|
||||
tenant_id=active_tenant_id,
|
||||
branch_id=active_branch_id or getattr(user, "branch_id", None),
|
||||
allow_cross_branch=allow_cross_branch,
|
||||
allow_cross_tenant=allow_cross_tenant,
|
||||
allow_all_clients=allow_all_clients,
|
||||
own_only=own_only,
|
||||
locked_partner_id=locked_partner_id,
|
||||
can_assign_partner=can_assign_partner,
|
||||
can_change_branch=can_change_branch,
|
||||
can_change_tenant=can_change_tenant,
|
||||
)
|
||||
|
||||
|
||||
def effective_partner_id(row: dict):
|
||||
return row.get("assoc_partner_user_id") or row.get("partner_id")
|
||||
|
||||
|
||||
def effective_tenant_id(row: dict):
|
||||
return row.get("assoc_firm_tenant_id") or row.get("tenant_id")
|
||||
|
||||
|
||||
def effective_branch_id(row: dict):
|
||||
return row.get("branch_id")
|
||||
|
||||
|
||||
def can_view_client_row(scope: ClientAccessScope, row: dict, *, user_id: int) -> bool:
|
||||
if scope.allow_all_clients:
|
||||
return True
|
||||
|
||||
if not scope.allow_cross_tenant and effective_tenant_id(row) != scope.tenant_id:
|
||||
return False
|
||||
|
||||
branch_id = effective_branch_id(row)
|
||||
if not scope.allow_cross_branch and scope.branch_id and branch_id and branch_id != scope.branch_id:
|
||||
return False
|
||||
|
||||
if scope.own_only and scope.locked_partner_id and effective_partner_id(row) != scope.locked_partner_id:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,178 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.clients.access import ClientAccessScope
|
||||
from app.modules.clients.schemas import ClientAuditLogOut, ClientFilterOptions, ClientListResponse, ClientOut, ClientUpdate, ClientCreate
|
||||
from app.modules.clients.service import (
|
||||
activate_client_service,
|
||||
archive_client_service,
|
||||
create_client_service,
|
||||
deactivate_client_service,
|
||||
export_clients_csv,
|
||||
get_client_or_404,
|
||||
get_filter_options,
|
||||
list_client_audit_logs,
|
||||
list_clients_payload,
|
||||
restore_client_service,
|
||||
update_client_service,
|
||||
)
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/v1/clients", tags=["clients-api"])
|
||||
|
||||
def _api_scope_from_user(db, user):
|
||||
def has(code: str):
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
own_only = has("clients.view.own_only") or not has("clients.assign_partner")
|
||||
return ClientAccessScope(
|
||||
tenant_id=user.tenant_id,
|
||||
branch_id=user.branch_id,
|
||||
allow_cross_branch=has("clients.cross_branch"),
|
||||
allow_cross_tenant=has("clients.cross_tenant"),
|
||||
own_only=own_only,
|
||||
locked_partner_id=user.id if own_only else None,
|
||||
can_assign_partner=has("clients.assign_partner"),
|
||||
can_change_branch=has("clients.cross_branch"),
|
||||
can_change_tenant=has("clients.cross_tenant"),
|
||||
)
|
||||
|
||||
@router.get("/filters", response_model=ClientFilterOptions)
|
||||
def api_client_filters():
|
||||
return get_filter_options()
|
||||
|
||||
@router.get("", response_model=ClientListResponse)
|
||||
def api_list_clients(
|
||||
q: str = Query("", max_length=100),
|
||||
status: str = Query("", max_length=20),
|
||||
client_type: str = Query("", max_length=100),
|
||||
partner_id: int | None = Query(None),
|
||||
include_archived: bool = Query(False),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
sort_by: str = Query("client_name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: Session = Depends(get_common_db),
|
||||
user=Depends(require_login),
|
||||
):
|
||||
require_permission(db, user, "clients.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
if scope.own_only:
|
||||
partner_id = scope.locked_partner_id
|
||||
return list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@router.get("/export")
|
||||
def api_export_clients(
|
||||
q: str = Query("", max_length=100),
|
||||
status: str = Query("", max_length=20),
|
||||
client_type: str = Query("", max_length=100),
|
||||
partner_id: int | None = Query(None),
|
||||
include_archived: bool = Query(False),
|
||||
sort_by: str = Query("client_name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: Session = Depends(get_common_db),
|
||||
user=Depends(require_login),
|
||||
):
|
||||
require_permission(db, user, "clients.export")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
if scope.own_only:
|
||||
partner_id = scope.locked_partner_id
|
||||
payload = list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
page=1,
|
||||
per_page=10000,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
csv_text = export_clients_csv(payload)
|
||||
return Response(content=csv_text, media_type="text/csv", headers={"Content-Disposition": "attachment; filename=clients_export.csv"})
|
||||
|
||||
@router.get("/{client_id}", response_model=ClientOut)
|
||||
def api_get_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
if scope.own_only and row.partner_id != scope.locked_partner_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
return row
|
||||
|
||||
@router.post("", response_model=ClientOut, status_code=201)
|
||||
def api_create_client(data: ClientCreate, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.create")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
return create_client_service(db, data=data, actor_user_id=user.id, scope=scope)
|
||||
|
||||
@router.put("/{client_id}", response_model=ClientOut)
|
||||
def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.edit")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
if scope.own_only and row.partner_id != scope.locked_partner_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
return update_client_service(db, row=row, data=data, actor_user_id=user.id, scope=scope)
|
||||
|
||||
@router.post("/{client_id}/deactivate", response_model=ClientOut)
|
||||
def api_deactivate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.deactivate")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return deactivate_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/activate", response_model=ClientOut)
|
||||
def api_activate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.activate")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return activate_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/archive", response_model=ClientOut)
|
||||
def api_archive_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.archive")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return archive_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/restore", response_model=ClientOut)
|
||||
def api_restore_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.restore")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return restore_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.get("/{client_id}/audit-logs", response_model=list[ClientAuditLogOut])
|
||||
def api_client_audit_logs(client_id: int, limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.audit_log.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return list_client_audit_logs(db, row=row, limit=limit)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients.association_models import ClientAssociation
|
||||
|
||||
|
||||
def get_active_association(db: Session, client_id: int):
|
||||
stmt = (
|
||||
select(ClientAssociation)
|
||||
.where(ClientAssociation.client_id == client_id)
|
||||
.limit(1)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def ensure_active_association(db: Session, client_id: int):
|
||||
row = get_active_association(db, client_id)
|
||||
if row:
|
||||
return row
|
||||
|
||||
row = ClientAssociation(
|
||||
client_id=client_id,
|
||||
association_type="firm",
|
||||
created_source="system_admin",
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_association_fields(db: Session, client_id: int, **fields):
|
||||
row = ensure_active_association(db, client_id)
|
||||
for key, value in fields.items():
|
||||
if hasattr(row, key):
|
||||
setattr(row, key, value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ClientAssociation(CommonBase):
|
||||
__tablename__ = "client_associations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
association_type: Mapped[str] = mapped_column(String(50), nullable=False, default="firm")
|
||||
firm_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
consultant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
partner_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
created_source: Mapped[str] = mapped_column(String(50), nullable=False, default="system_admin")
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
def build_client_association(current_user, role_name, selected_partner_id=None):
|
||||
role = (role_name or '').lower()
|
||||
if role in ('system admin', 'firm admin'):
|
||||
return {
|
||||
'association_type': 'firm',
|
||||
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
|
||||
'partner_user_id': selected_partner_id,
|
||||
'created_source': 'firm_admin',
|
||||
}
|
||||
if role == 'partner':
|
||||
return {
|
||||
'association_type': 'firm',
|
||||
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
|
||||
'partner_user_id': current_user.id,
|
||||
'created_source': 'partner',
|
||||
}
|
||||
if role == 'consultant':
|
||||
return {
|
||||
'association_type': 'consultant',
|
||||
'consultant_id': current_user.id,
|
||||
'created_source': 'consultant',
|
||||
}
|
||||
return {
|
||||
'association_type': 'self_service_unassigned',
|
||||
'created_source': 'self_service',
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.profile_service import profile_photo_url
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
def _initials(name: str | None, email: str | None = None) -> str:
|
||||
source = (name or email or "Auditor").strip()
|
||||
parts = [p for p in source.replace("@", " ").replace(".", " ").split() if p]
|
||||
if not parts:
|
||||
return "AU"
|
||||
if len(parts) == 1:
|
||||
return parts[0][:2].upper()
|
||||
return (parts[0][:1] + parts[-1][:1]).upper()
|
||||
|
||||
|
||||
def _contact_card_from_user(
|
||||
*,
|
||||
user: User | None,
|
||||
tenant_name: str | None,
|
||||
branch_name: str | None,
|
||||
source_label: str,
|
||||
) -> dict:
|
||||
if not user:
|
||||
return {
|
||||
"available": False,
|
||||
"name": "Firm team",
|
||||
"designation": "Audit support team",
|
||||
"qualification": None,
|
||||
"email": None,
|
||||
"mobile": None,
|
||||
"photo_url": None,
|
||||
"initials": "FT",
|
||||
"firm_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"source_label": source_label,
|
||||
}
|
||||
|
||||
name = getattr(user, "full_name", None) or getattr(user, "email", None) or "Firm team"
|
||||
designation = getattr(user, "designation", None) or source_label or "Auditor"
|
||||
return {
|
||||
"available": True,
|
||||
"name": name,
|
||||
"designation": designation,
|
||||
"qualification": getattr(user, "qualification", None),
|
||||
"email": getattr(user, "email", None),
|
||||
"mobile": getattr(user, "mobile", None),
|
||||
"photo_url": profile_photo_url(user),
|
||||
"initials": _initials(name, getattr(user, "email", None)),
|
||||
"firm_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"source_label": source_label,
|
||||
}
|
||||
|
||||
|
||||
def build_client_auditor_card(db: Session, client_row: dict | None) -> dict:
|
||||
"""Return a client-facing contact card for the assigned auditor/partner.
|
||||
|
||||
Priority:
|
||||
1. Client assigned partner (`partner_id`).
|
||||
2. Default review partner, if no assigned partner exists.
|
||||
3. Firm team fallback using tenant/branch names.
|
||||
|
||||
This reuses Phase 7Q.3 user profile fields and does not create new tables.
|
||||
"""
|
||||
if not client_row:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=None,
|
||||
branch_name=None,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
tenant_name = client_row.get("tenant_name")
|
||||
branch_name = client_row.get("branch_name")
|
||||
tenant_id = client_row.get("tenant_id")
|
||||
branch_id = client_row.get("branch_id")
|
||||
|
||||
partner_id = client_row.get("partner_id") or client_row.get("assoc_partner_user_id")
|
||||
review_partner_id = client_row.get("default_review_partner_user_id")
|
||||
|
||||
target_user_id = partner_id or review_partner_id
|
||||
source_label = "Assigned Auditor" if partner_id else "Review Partner"
|
||||
|
||||
if not target_user_id:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(User, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.join(Branch, Branch.id == User.branch_id, isouter=True)
|
||||
.where(User.id == int(target_user_id), User.deleted_at.is_(None))
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(User.tenant_id == int(tenant_id))
|
||||
result = db.execute(stmt).first()
|
||||
if not result:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
user, resolved_tenant_name, resolved_branch_name = result
|
||||
return _contact_card_from_user(
|
||||
user=user,
|
||||
tenant_name=resolved_tenant_name or tenant_name,
|
||||
branch_name=resolved_branch_name or branch_name,
|
||||
source_label=source_label,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
ENGAGEMENT_MODES = [
|
||||
"internal_managed",
|
||||
"self_tracked",
|
||||
"hybrid",
|
||||
]
|
||||
|
||||
ASSOCIATION_TYPES = [
|
||||
"firm",
|
||||
"consultant",
|
||||
"firm_consultant",
|
||||
"self_service_unassigned",
|
||||
]
|
||||
|
||||
CLIENT_TYPES = [
|
||||
"Proprietorship",
|
||||
"Partnership",
|
||||
"LLP",
|
||||
"Private Limited Company",
|
||||
"Public Limited Company",
|
||||
"Trust",
|
||||
"Society",
|
||||
"AOP",
|
||||
"HUF",
|
||||
"NRI",
|
||||
"Other",
|
||||
]
|
||||
|
||||
CLIENT_STATUS = ["active", "inactive", "archived"]
|
||||
|
||||
CLIENT_CATEGORY_OPTIONS = [
|
||||
"Audit", "Tax", "GST", "Compliance", "Payroll", "Advisory", "Litigation", "Internal", "Other",
|
||||
]
|
||||
|
||||
RISK_CATEGORIES = ["low", "medium", "high", "critical"]
|
||||
|
||||
CLIENT_SORT_FIELDS = {
|
||||
"client_code": "client_code",
|
||||
"client_name": "client_name",
|
||||
"client_type": "client_type",
|
||||
"status": "status",
|
||||
"created_at_utc": "created_at_utc",
|
||||
"updated_at_utc": "updated_at_utc",
|
||||
"onboarding_date": "onboarding_date",
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ClientListFilters:
|
||||
q: str = ""
|
||||
status: str = ""
|
||||
client_type: str = ""
|
||||
partner_id: int | None = None
|
||||
include_archived: bool = False
|
||||
page: int = 1
|
||||
per_page: int = 10
|
||||
sort_by: str = "client_name"
|
||||
sort_order: str = "asc"
|
||||
|
||||
@classmethod
|
||||
def from_params(cls, **kwargs):
|
||||
partner_id = kwargs.get("partner_id")
|
||||
if partner_id in ("", None):
|
||||
partner_id = None
|
||||
elif not isinstance(partner_id, int):
|
||||
partner_id = int(partner_id)
|
||||
include_archived = kwargs.get("include_archived", False)
|
||||
if isinstance(include_archived, str):
|
||||
include_archived = include_archived.lower() in ("1", "true", "yes", "on")
|
||||
return cls(
|
||||
q=kwargs.get("q", "") or "",
|
||||
status=kwargs.get("status", "") or "",
|
||||
client_type=kwargs.get("client_type", "") or "",
|
||||
partner_id=partner_id,
|
||||
include_archived=include_archived,
|
||||
page=max(int(kwargs.get("page", 1) or 1), 1),
|
||||
per_page=min(max(int(kwargs.get("per_page", 10) or 10), 1), 100),
|
||||
sort_by=kwargs.get("sort_by", "client_name") or "client_name",
|
||||
sort_order=kwargs.get("sort_order", "asc") or "asc",
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.modules.clients.schemas import ClientCreate
|
||||
from app.modules.clients.service import create_client_service
|
||||
|
||||
TEMPLATE_COLUMNS = [
|
||||
"uploader_user_id",
|
||||
"firm_tenant_id",
|
||||
"partner_user_id",
|
||||
"branch_id",
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"engagement_mode",
|
||||
"email",
|
||||
"portal_password",
|
||||
"portal_password_confirm",
|
||||
"mobile",
|
||||
"pan",
|
||||
"gstin",
|
||||
"tan",
|
||||
"cin_llpin",
|
||||
"msme_no",
|
||||
"iec_code",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"alternate_mobile",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"client_category",
|
||||
"risk_category",
|
||||
"onboarding_date",
|
||||
"closing_date",
|
||||
"notes",
|
||||
"status",
|
||||
"gst_applicable",
|
||||
"income_tax_applicable",
|
||||
"tds_applicable",
|
||||
"roc_applicable",
|
||||
"audit_applicable",
|
||||
"pf_applicable",
|
||||
"esi_applicable",
|
||||
"professional_tax_applicable",
|
||||
"payroll_applicable",
|
||||
"msme_applicable",
|
||||
"import_export_applicable",
|
||||
]
|
||||
|
||||
BOOL_FIELDS = {
|
||||
"gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable",
|
||||
"audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable",
|
||||
"payroll_applicable", "msme_applicable", "import_export_applicable",
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class ImportPreview:
|
||||
valid_rows: list[dict]
|
||||
errors: list[dict]
|
||||
total_rows: int
|
||||
|
||||
|
||||
def _clean(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
txt = str(value).strip()
|
||||
return txt or None
|
||||
|
||||
|
||||
def _to_bool(value: Any) -> bool:
|
||||
txt = str(value or '').strip().lower()
|
||||
return txt in {'1','true','yes','y','on'}
|
||||
|
||||
|
||||
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'clients_import'
|
||||
ws.append(TEMPLATE_COLUMNS)
|
||||
sample = [
|
||||
current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '',
|
||||
'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123',
|
||||
'9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active',
|
||||
'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no'
|
||||
]
|
||||
ws.append(sample)
|
||||
ref = wb.create_sheet('instructions')
|
||||
ref.append(['Field', 'Notes'])
|
||||
ref.append(['uploader_user_id', 'Must match the logged-in uploader user id exactly.'])
|
||||
ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.'])
|
||||
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
|
||||
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
|
||||
ref.append(['email', 'Used as the client frontend login email.'])
|
||||
ref.append(['portal_password', 'Minimum 8 characters.'])
|
||||
ref.append(['portal_password_confirm', 'Must match portal_password.'])
|
||||
bio = io.BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def _row_dict(ws, row_idx: int) -> dict[str, Any]:
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
values = [c.value for c in ws[row_idx]]
|
||||
return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]}
|
||||
|
||||
|
||||
def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview:
|
||||
wb = load_workbook(io.BytesIO(upload_bytes), data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
missing = [c for c in TEMPLATE_COLUMNS if c not in headers]
|
||||
if missing:
|
||||
return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0)
|
||||
|
||||
valid_rows = []
|
||||
errors = []
|
||||
active_tenant_id = int(scope.tenant_id)
|
||||
active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
raw = _row_dict(ws, row_idx)
|
||||
if not any(v not in (None, '') for v in raw.values()):
|
||||
continue
|
||||
msgs: list[str] = []
|
||||
cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()}
|
||||
|
||||
try:
|
||||
uploader_user_id = int(cleaned.get('uploader_user_id') or 0)
|
||||
except Exception:
|
||||
uploader_user_id = 0
|
||||
try:
|
||||
firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0)
|
||||
except Exception:
|
||||
firm_tenant_id = 0
|
||||
try:
|
||||
partner_user_id = int(cleaned.get('partner_user_id') or 0)
|
||||
except Exception:
|
||||
partner_user_id = 0
|
||||
try:
|
||||
branch_id = int(cleaned.get('branch_id') or 0)
|
||||
except Exception:
|
||||
branch_id = 0
|
||||
|
||||
if uploader_user_id != int(current_user.id):
|
||||
msgs.append('uploader_user_id must match the currently logged-in user id.')
|
||||
if firm_tenant_id != active_tenant_id:
|
||||
msgs.append('firm_tenant_id must match the active firm/tenant context of the uploader.')
|
||||
partner = repository.get_partner_for_tenant(db, partner_user_id=partner_user_id, tenant_id=firm_tenant_id) if partner_user_id else None
|
||||
if not partner:
|
||||
msgs.append('partner_user_id must belong to an active Partner user in the same firm.')
|
||||
if 'partner' in role_names and partner_user_id != int(current_user.id):
|
||||
msgs.append('Partner uploader can import only for their own partner_user_id.')
|
||||
|
||||
if branch_id:
|
||||
branch = repository.get_branch(db, branch_id)
|
||||
if not branch or int(branch.tenant_id) != firm_tenant_id:
|
||||
msgs.append('branch_id must belong to the same firm/tenant.')
|
||||
else:
|
||||
branch_id = int(getattr(partner, 'branch_id', None) or active_branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
if not branch_id:
|
||||
msgs.append('branch_id is required when uploader and partner have no branch mapped.')
|
||||
|
||||
payload = {
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id or None,
|
||||
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
|
||||
'client_code': cleaned.get('client_code') or '',
|
||||
'client_name': cleaned.get('client_name') or '',
|
||||
'trade_name': None,
|
||||
'client_type': cleaned.get('client_type') or 'Other',
|
||||
'pan': cleaned.get('pan'),
|
||||
'gstin': cleaned.get('gstin'),
|
||||
'tan': cleaned.get('tan'),
|
||||
'cin_llpin': cleaned.get('cin_llpin'),
|
||||
'msme_no': cleaned.get('msme_no'),
|
||||
'iec_code': cleaned.get('iec_code'),
|
||||
'contact_person_name': cleaned.get('contact_person_name'),
|
||||
'contact_person_designation': cleaned.get('contact_person_designation'),
|
||||
'mobile': cleaned.get('mobile'),
|
||||
'alternate_mobile': cleaned.get('alternate_mobile'),
|
||||
'email': cleaned.get('email'),
|
||||
'alternate_email': cleaned.get('alternate_email'),
|
||||
'address_line_1': cleaned.get('address_line_1'),
|
||||
'address_line_2': cleaned.get('address_line_2'),
|
||||
'city': cleaned.get('city'),
|
||||
'state': cleaned.get('state'),
|
||||
'pincode': cleaned.get('pincode'),
|
||||
'country': cleaned.get('country') or 'India',
|
||||
'status': cleaned.get('status') or 'active',
|
||||
'client_category': cleaned.get('client_category'),
|
||||
'risk_category': cleaned.get('risk_category'),
|
||||
'onboarding_date': cleaned.get('onboarding_date'),
|
||||
'closing_date': cleaned.get('closing_date'),
|
||||
'notes': cleaned.get('notes'),
|
||||
'gst_applicable': cleaned.get('gst_applicable') or False,
|
||||
'income_tax_applicable': cleaned.get('income_tax_applicable') or False,
|
||||
'tds_applicable': cleaned.get('tds_applicable') or False,
|
||||
'roc_applicable': cleaned.get('roc_applicable') or False,
|
||||
'audit_applicable': cleaned.get('audit_applicable') or False,
|
||||
'pf_applicable': cleaned.get('pf_applicable') or False,
|
||||
'esi_applicable': cleaned.get('esi_applicable') or False,
|
||||
'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False,
|
||||
'payroll_applicable': cleaned.get('payroll_applicable') or False,
|
||||
'msme_applicable': cleaned.get('msme_applicable') or False,
|
||||
'import_export_applicable': cleaned.get('import_export_applicable') or False,
|
||||
}
|
||||
|
||||
try:
|
||||
ClientCreate(**payload)
|
||||
except Exception as exc:
|
||||
msgs.append(str(exc))
|
||||
|
||||
if not cleaned.get('portal_password'):
|
||||
msgs.append('portal_password is required for imported clients.')
|
||||
if cleaned.get('portal_password') != cleaned.get('portal_password_confirm'):
|
||||
msgs.append('portal_password and portal_password_confirm must match.')
|
||||
|
||||
# intra-file duplicate client codes
|
||||
if any(v.get('client_code') == payload['client_code'] and v.get('tenant_id') == firm_tenant_id for v in valid_rows):
|
||||
msgs.append('Duplicate client_code found within the same upload file.')
|
||||
|
||||
if msgs:
|
||||
errors.append({'row_number': row_idx, 'messages': msgs, 'row': cleaned})
|
||||
continue
|
||||
|
||||
valid_rows.append({
|
||||
'row_number': row_idx,
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id,
|
||||
'client_payload': payload,
|
||||
'portal_password': cleaned.get('portal_password'),
|
||||
'portal_password_confirm': cleaned.get('portal_password_confirm'),
|
||||
})
|
||||
|
||||
return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors))
|
||||
|
||||
|
||||
def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
||||
return json.dumps(valid_rows, default=str)
|
||||
|
||||
|
||||
def deserialize_preview_rows(raw: str) -> list[dict]:
|
||||
rows = json.loads(raw or '[]')
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def commit_import(db: Session, *, current_user, scope, current_user_roles: list[str], preview_rows: list[dict]) -> dict:
|
||||
created = []
|
||||
failures = []
|
||||
for item in preview_rows:
|
||||
try:
|
||||
data = ClientCreate(**item['client_payload'])
|
||||
row = create_client_service(
|
||||
db,
|
||||
data=data,
|
||||
actor_user_id=current_user.id,
|
||||
scope=scope,
|
||||
current_user_roles=current_user_roles,
|
||||
portal_password=item.get('portal_password'),
|
||||
portal_password_confirm=item.get('portal_password_confirm'),
|
||||
)
|
||||
created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name})
|
||||
except Exception as exc:
|
||||
failures.append({'row_number': item.get('row_number'), 'message': str(getattr(exc, 'detail', exc))})
|
||||
return {'created': created, 'failures': failures}
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class Client(CommonBase):
|
||||
__tablename__ = "clients"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"),
|
||||
UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
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)
|
||||
default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True)
|
||||
client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
cin_llpin: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
msme_no: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
iec_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contact_person_designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
alternate_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
|
||||
client_category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
risk_category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
onboarding_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
closing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
gst_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
income_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
tds_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
roc_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
audit_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
pf_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
esi_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
professional_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
payroll_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
msme_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
import_export_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
portal_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=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)
|
||||
|
||||
|
||||
class ClientAuditLog(CommonBase):
|
||||
__tablename__ = "client_audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True)
|
||||
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)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
@@ -0,0 +1,15 @@
|
||||
CLIENT_PERMISSION_CODES = {
|
||||
"view": "clients.view",
|
||||
"create": "clients.create",
|
||||
"edit": "clients.edit",
|
||||
"deactivate": "clients.deactivate",
|
||||
"activate": "clients.activate",
|
||||
"archive": "clients.archive",
|
||||
"restore": "clients.restore",
|
||||
"assign_partner": "clients.assign_partner",
|
||||
"cross_branch": "clients.cross_branch",
|
||||
"cross_tenant": "clients.cross_tenant",
|
||||
"export": "clients.export",
|
||||
"audit_log_view": "clients.audit_log.view",
|
||||
"view_own_only": "clients.view.own_only",
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "ready_for_review", "rework"}
|
||||
CLOSED_TASK_STATUSES = {"completed", "approved", "closed", "not_applicable"}
|
||||
|
||||
|
||||
def _client_id(client_row: dict[str, Any]) -> int:
|
||||
return int(client_row.get("id") or 0)
|
||||
|
||||
|
||||
def _tenant_id(client_row: dict[str, Any]) -> int:
|
||||
return int(client_row.get("tenant_id") or 0)
|
||||
|
||||
|
||||
def list_client_engagements(db: Session, client_row: dict[str, Any], *, limit: int = 200, financial_year: str | None = None) -> list[ClientServiceSubscription]:
|
||||
"""Return engagements/subscriptions visible to the logged-in client."""
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceSubscription.client_id == _client_id(client_row),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
rows = db.execute(
|
||||
query.order_by(
|
||||
ClientServiceSubscription.current_due_date.asc().nulls_last(),
|
||||
ClientServiceSubscription.updated_at_utc.desc(),
|
||||
)
|
||||
.limit(max(1, min(int(limit or 200), 500)))
|
||||
).scalars().all()
|
||||
return rows
|
||||
|
||||
|
||||
def list_client_tasks_for_engagement(db: Session, client_row: dict[str, Any], engagement_id: int) -> list[ClientServiceTaskInstance]:
|
||||
return db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.subscription_id == int(engagement_id),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_client_engagement(db: Session, client_row: dict[str, Any], engagement_id: int, *, financial_year: str | None = None) -> ClientServiceSubscription | None:
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.id == int(engagement_id),
|
||||
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceSubscription.client_id == _client_id(client_row),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_task(db: Session, client_row: dict[str, Any], task_id: int, *, financial_year: str | None = None) -> ClientServiceTaskInstance | None:
|
||||
query = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == int(task_id),
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_client_visible_comments(db: Session, client_row: dict[str, Any], *, limit: int = 100, financial_year: str | None = None) -> list[ServiceTaskComment]:
|
||||
query = (
|
||||
select(ServiceTaskComment)
|
||||
.options(
|
||||
selectinload(ServiceTaskComment.created_by),
|
||||
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ServiceTaskComment.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == _tenant_id(client_row),
|
||||
ServiceTaskComment.visibility == "client",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
return db.execute(
|
||||
query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(max(1, min(int(limit or 100), 300)))
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def create_client_reply(db: Session, *, client_row: dict[str, Any], task: ClientServiceTaskInstance, message: str, user) -> ServiceTaskComment:
|
||||
clean_message = (message or "").strip()
|
||||
if not clean_message:
|
||||
raise ValueError("Reply message is required.")
|
||||
if len(clean_message) > 4000:
|
||||
raise ValueError("Reply message is too long. Please keep it within 4000 characters.")
|
||||
comment = ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type="client_clarification",
|
||||
visibility="client",
|
||||
message=clean_message,
|
||||
created_by_user_id=getattr(user, "id", None),
|
||||
)
|
||||
db.add(comment)
|
||||
db.flush()
|
||||
return comment
|
||||
|
||||
|
||||
def list_client_engagement_documents(db: Session, client_row: dict[str, Any], *, engagement_id: int | None = None, financial_year: str | None = None) -> list[EngagementDocument]:
|
||||
stmt = (
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions), selectinload(EngagementDocument.engagement).selectinload(ClientServiceSubscription.catalogue))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == _tenant_id(client_row),
|
||||
EngagementDocument.client_id == _client_id(client_row),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if engagement_id is not None:
|
||||
stmt = stmt.where(EngagementDocument.engagement_id == int(engagement_id))
|
||||
if financial_year:
|
||||
stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip())
|
||||
return db.execute(stmt.order_by(EngagementDocument.updated_at_utc.desc())).unique().scalars().all()
|
||||
|
||||
|
||||
def list_client_permanent_documents(db: Session, client_row: dict[str, Any]) -> list[PermanentClientDocument]:
|
||||
return db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == _tenant_id(client_row),
|
||||
PermanentClientDocument.client_id == _client_id(client_row),
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc())
|
||||
).unique().scalars().all()
|
||||
|
||||
|
||||
def build_client_portal_summary(db: Session, client_row: dict[str, Any], *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
engagements = list_client_engagements(db, client_row, limit=500, financial_year=financial_year)
|
||||
engagement_ids = [row.id for row in engagements]
|
||||
today = date.today()
|
||||
|
||||
task_rows: list[ClientServiceTaskInstance] = []
|
||||
if engagement_ids:
|
||||
task_rows = db.execute(
|
||||
select(ClientServiceTaskInstance).where(
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.subscription_id.in_(engagement_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
status_counter = Counter((task.status or "pending") for task in task_rows)
|
||||
open_tasks = [task for task in task_rows if (task.status or "pending") in OPEN_TASK_STATUSES]
|
||||
overdue_tasks = [
|
||||
task for task in open_tasks
|
||||
if task.internal_target_date is not None and task.internal_target_date < today
|
||||
]
|
||||
due_soon_engagements = [
|
||||
row for row in engagements
|
||||
if row.current_due_date is not None and row.current_due_date >= today
|
||||
][:10]
|
||||
|
||||
pending_from_client = 0
|
||||
with_firm = 0
|
||||
completed = 0
|
||||
clarification_required = 0
|
||||
for row in engagements:
|
||||
tasks_for_eng = [t for t in task_rows if t.subscription_id == row.id]
|
||||
statuses = {(t.status or "pending") for t in tasks_for_eng}
|
||||
if statuses & {"blocked", "client_pending", "clarification_required"}:
|
||||
clarification_required += 1
|
||||
elif tasks_for_eng and all((t.status or "pending") in CLOSED_TASK_STATUSES for t in tasks_for_eng):
|
||||
completed += 1
|
||||
elif statuses & {"pending"}:
|
||||
pending_from_client += 1
|
||||
else:
|
||||
with_firm += 1
|
||||
|
||||
return {
|
||||
"engagements": engagements,
|
||||
"task_rows": task_rows,
|
||||
"status_counter": status_counter,
|
||||
"total_engagements": len(engagements),
|
||||
"open_tasks": len(open_tasks),
|
||||
"overdue_tasks": len(overdue_tasks),
|
||||
"completed_tasks": status_counter.get("completed", 0) + status_counter.get("approved", 0) + status_counter.get("closed", 0),
|
||||
"due_soon_engagements": due_soon_engagements,
|
||||
"pending_from_client": pending_from_client,
|
||||
"with_firm": with_firm,
|
||||
"clarification_required": clarification_required,
|
||||
"completed_engagements": completed,
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
def _safe_sort(sort_by: str, sort_order: str):
|
||||
attr_name = CLIENT_SORT_FIELDS.get(sort_by, "client_name")
|
||||
column = getattr(Client, attr_name)
|
||||
return desc(column) if sort_order == "desc" else asc(column)
|
||||
|
||||
|
||||
def build_clients_query(
|
||||
*,
|
||||
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,
|
||||
):
|
||||
assoc = ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_name"),
|
||||
Tenant.name.label("tenant_name"),
|
||||
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)
|
||||
.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)
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Client.status == status)
|
||||
|
||||
if client_type:
|
||||
stmt = stmt.where(Client.client_type == client_type)
|
||||
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
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",
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def get_client_by_id(db: Session, client_id: int):
|
||||
return db.get(Client, client_id)
|
||||
|
||||
|
||||
def get_client_by_code(db: Session, *, tenant_id: int, client_code: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.client_code == client_code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_by_pan(db: Session, *, tenant_id: int, pan: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.pan == pan)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_by_gstin(db: Session, *, tenant_id: int, gstin: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.gstin == gstin)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_client(db: Session, payload: dict):
|
||||
row = Client(**payload)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_client(db: Session, row: Client, payload: dict):
|
||||
for key, value in payload.items():
|
||||
setattr(row, key, value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def write_audit_log(db: Session, **kwargs):
|
||||
row = ClientAuditLog(**kwargs)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def list_audit_logs(db: Session, *, client_id: int, limit: int = 50):
|
||||
stmt = (
|
||||
select(ClientAuditLog)
|
||||
.where(ClientAuditLog.client_id == client_id)
|
||||
.order_by(ClientAuditLog.created_at_utc.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def list_tenants(db: Session):
|
||||
return db.execute(
|
||||
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def list_branches_for_tenant(db: Session, tenant_id: int):
|
||||
stmt = (
|
||||
select(Branch)
|
||||
.where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))
|
||||
.order_by(Branch.name.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def list_partners_for_scope(db: Session, *, tenant_id: int, branch_id: int | None = None):
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
if branch_id:
|
||||
stmt = stmt.where(User.branch_id == branch_id)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def get_branch(db: Session, branch_id: int):
|
||||
return db.execute(
|
||||
select(Branch).where(Branch.id == branch_id, Branch.is_active.is_(True))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_partner(db: Session, partner_id: int):
|
||||
return db.execute(
|
||||
select(User).where(User.id == partner_id, User.is_active.is_(True), User.deleted_at.is_(None))
|
||||
).scalar_one_or_none()
|
||||
|
||||
def list_all_branches(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
Branch,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(Tenant, Tenant.id == Branch.tenant_id)
|
||||
.where(Branch.is_active.is_(True))
|
||||
.order_by(Tenant.name.asc(), Branch.name.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for branch, tenant_name in db.execute(stmt).all():
|
||||
branch.tenant_name = tenant_name
|
||||
rows.append(branch)
|
||||
return rows
|
||||
|
||||
def list_all_partners(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
User,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for user, tenant_name in db.execute(stmt).all():
|
||||
user.tenant_name = tenant_name
|
||||
rows.append(user)
|
||||
return rows
|
||||
|
||||
def list_all_branches(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
Branch,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(Tenant, Tenant.id == Branch.tenant_id)
|
||||
.where(Branch.is_active.is_(True))
|
||||
.order_by(Tenant.name.asc(), Branch.name.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for branch, tenant_name in db.execute(stmt).all():
|
||||
branch.tenant_name = tenant_name
|
||||
rows.append(branch)
|
||||
return rows
|
||||
|
||||
|
||||
def list_all_partners(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
User,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for user, tenant_name in db.execute(stmt).all():
|
||||
user.tenant_name = tenant_name
|
||||
rows.append(user)
|
||||
return rows
|
||||
|
||||
|
||||
def get_portal_client_for_user(db: Session, *, user: User):
|
||||
email = (getattr(user, "email", "") or "").strip().lower()
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
if not email or not tenant_id:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_name"),
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.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)
|
||||
.where(
|
||||
Client.tenant_id == tenant_id,
|
||||
Client.is_archived.is_(False),
|
||||
or_(Client.email.ilike(email), Client.alternate_email.ilike(email)),
|
||||
)
|
||||
.order_by(
|
||||
case((Client.status == "active", 0), else_=1),
|
||||
Client.client_name.asc(),
|
||||
Client.id.asc(),
|
||||
)
|
||||
)
|
||||
result = db.execute(stmt).first()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
client, partner_name, branch_name, tenant_name = result
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"partner_name": partner_name,
|
||||
"branch_name": branch_name,
|
||||
"tenant_name": tenant_name,
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, *, email: str, exclude_user_id: int | None = None):
|
||||
email_clean = (email or "").strip().lower()
|
||||
if not email_clean:
|
||||
return None
|
||||
stmt = select(User).where(User.email.ilike(email_clean), User.deleted_at.is_(None))
|
||||
if exclude_user_id:
|
||||
stmt = stmt.where(User.id != exclude_user_id)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_tenant(db: Session, tenant_id: int):
|
||||
return db.execute(select(Tenant).where(Tenant.id == tenant_id, Tenant.is_active.is_(True))).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_partner_for_tenant(db: Session, *, partner_user_id: int, tenant_id: int):
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
User.id == partner_user_id,
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
Role.name == "Partner",
|
||||
)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_role_by_name(db: Session, role_name: str):
|
||||
return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_portal_user(db: Session, *, email: str, full_name: str, tenant_id: int, branch_id: int, password: str):
|
||||
row = User(
|
||||
email=(email or '').strip().lower(),
|
||||
full_name=(full_name or '').strip(),
|
||||
password_hash=hash_password(password),
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
is_active=True,
|
||||
allow_login=True,
|
||||
is_locked=False,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def ensure_user_role(db: Session, *, user_id: int, role_id: int):
|
||||
existing = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id)).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
row = UserRole(user_id=user_id, role_id=role_id)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, model_validator
|
||||
|
||||
from app.modules.clients.constants import (
|
||||
CLIENT_CATEGORY_OPTIONS,
|
||||
CLIENT_SORT_FIELDS,
|
||||
CLIENT_STATUS,
|
||||
CLIENT_TYPES,
|
||||
ENGAGEMENT_MODES,
|
||||
RISK_CATEGORIES,
|
||||
)
|
||||
from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper
|
||||
|
||||
|
||||
class ClientBase(BaseModel):
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: str = "internal_managed"
|
||||
client_code: str
|
||||
client_name: str
|
||||
trade_name: Optional[str] = None
|
||||
client_type: str = "Other"
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
alternate_email: Optional[EmailStr] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = "India"
|
||||
status: str = "active"
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: bool = False
|
||||
income_tax_applicable: bool = False
|
||||
tds_applicable: bool = False
|
||||
roc_applicable: bool = False
|
||||
audit_applicable: bool = False
|
||||
pf_applicable: bool = False
|
||||
esi_applicable: bool = False
|
||||
professional_tax_applicable: bool = False
|
||||
payroll_applicable: bool = False
|
||||
msme_applicable: bool = False
|
||||
import_export_applicable: bool = False
|
||||
|
||||
@field_validator("client_code", "client_name", mode="before")
|
||||
@classmethod
|
||||
def required_text(cls, value):
|
||||
value = normalize_text(value)
|
||||
if not value:
|
||||
raise ValueError("This field is required.")
|
||||
return value
|
||||
|
||||
@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", "notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def clean_text(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pan", "gstin", "tan", mode="before")
|
||||
@classmethod
|
||||
def uppercase_codes(cls, value):
|
||||
return normalize_upper(value)
|
||||
|
||||
@field_validator("engagement_mode", mode="before")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
value = normalize_text(value) or "internal_managed"
|
||||
return value.lower()
|
||||
|
||||
@field_validator("engagement_mode")
|
||||
@classmethod
|
||||
def validate_engagement_mode(cls, value):
|
||||
if value not in ENGAGEMENT_MODES:
|
||||
raise ValueError("Invalid engagement mode.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile", mode="before")
|
||||
@classmethod
|
||||
def clean_mobile(cls, value):
|
||||
value = normalize_text(value)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
if value.startswith("+91"):
|
||||
value = value[3:]
|
||||
return value
|
||||
|
||||
@field_validator("pan")
|
||||
@classmethod
|
||||
def validate_pan(cls, value):
|
||||
if value and not PAN_RE.match(value):
|
||||
raise ValueError("Invalid PAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("gstin")
|
||||
@classmethod
|
||||
def validate_gstin(cls, value):
|
||||
if value and not GSTIN_RE.match(value):
|
||||
raise ValueError("Invalid GSTIN format.")
|
||||
return value
|
||||
|
||||
@field_validator("tan")
|
||||
@classmethod
|
||||
def validate_tan(cls, value):
|
||||
if value and not TAN_RE.match(value):
|
||||
raise ValueError("Invalid TAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value):
|
||||
if value and not MOBILE_RE.match(value):
|
||||
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
|
||||
return value
|
||||
|
||||
@field_validator("pincode", mode="before")
|
||||
@classmethod
|
||||
def clean_pincode(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pincode")
|
||||
@classmethod
|
||||
def validate_pincode(cls, value):
|
||||
if value and not PIN_RE.match(value):
|
||||
raise ValueError("Pincode must be a valid 6-digit code.")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_dates_and_assignment(self):
|
||||
if self.onboarding_date and self.closing_date and self.closing_date < self.onboarding_date:
|
||||
raise ValueError("Closing date cannot be earlier than onboarding date.")
|
||||
if self.engagement_mode == "internal_managed" and not self.partner_id:
|
||||
raise ValueError("Partner is required for internal managed clients.")
|
||||
return self
|
||||
|
||||
|
||||
class ClientCreate(ClientBase):
|
||||
pass
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
tenant_id: Optional[int] = None
|
||||
branch_id: Optional[int] = None
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
trade_name: Optional[str] = None
|
||||
client_type: Optional[str] = None
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
alternate_email: Optional[EmailStr] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: Optional[bool] = None
|
||||
income_tax_applicable: Optional[bool] = None
|
||||
tds_applicable: Optional[bool] = None
|
||||
roc_applicable: Optional[bool] = None
|
||||
audit_applicable: Optional[bool] = None
|
||||
pf_applicable: Optional[bool] = None
|
||||
esi_applicable: Optional[bool] = None
|
||||
professional_tax_applicable: Optional[bool] = None
|
||||
payroll_applicable: Optional[bool] = None
|
||||
msme_applicable: Optional[bool] = None
|
||||
import_export_applicable: Optional[bool] = None
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@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", "notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def clean_text(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pan", "gstin", "tan", mode="before")
|
||||
@classmethod
|
||||
def uppercase_codes(cls, value):
|
||||
return normalize_upper(value)
|
||||
|
||||
@field_validator("engagement_mode", mode="before")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
value = normalize_text(value) or None
|
||||
return value.lower() if value else None
|
||||
|
||||
@field_validator("engagement_mode")
|
||||
@classmethod
|
||||
def validate_engagement_mode(cls, value):
|
||||
if value is not None and value not in ENGAGEMENT_MODES:
|
||||
raise ValueError("Invalid engagement mode.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile", mode="before")
|
||||
@classmethod
|
||||
def clean_mobile(cls, value):
|
||||
value = normalize_text(value)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
if value.startswith("+91"):
|
||||
value = value[3:]
|
||||
return value
|
||||
|
||||
@field_validator("pan")
|
||||
@classmethod
|
||||
def validate_pan(cls, value):
|
||||
if value and not PAN_RE.match(value):
|
||||
raise ValueError("Invalid PAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("gstin")
|
||||
@classmethod
|
||||
def validate_gstin(cls, value):
|
||||
if value and not GSTIN_RE.match(value):
|
||||
raise ValueError("Invalid GSTIN format.")
|
||||
return value
|
||||
|
||||
@field_validator("tan")
|
||||
@classmethod
|
||||
def validate_tan(cls, value):
|
||||
if value and not TAN_RE.match(value):
|
||||
raise ValueError("Invalid TAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value):
|
||||
if value and not MOBILE_RE.match(value):
|
||||
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
|
||||
return value
|
||||
|
||||
@field_validator("pincode", mode="before")
|
||||
@classmethod
|
||||
def clean_pincode(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pincode")
|
||||
@classmethod
|
||||
def validate_pincode(cls, value):
|
||||
if value and not PIN_RE.match(value):
|
||||
raise ValueError("Pincode must be a valid 6-digit code.")
|
||||
return value
|
||||
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: str
|
||||
client_code: str
|
||||
client_name: str
|
||||
trade_name: Optional[str] = None
|
||||
client_type: str
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
alternate_email: Optional[str] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
status: str
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: bool
|
||||
income_tax_applicable: bool
|
||||
tds_applicable: bool
|
||||
roc_applicable: bool
|
||||
audit_applicable: bool
|
||||
pf_applicable: bool
|
||||
esi_applicable: bool
|
||||
professional_tax_applicable: bool
|
||||
payroll_applicable: bool
|
||||
msme_applicable: bool
|
||||
import_export_applicable: bool
|
||||
is_active: bool
|
||||
is_archived: bool
|
||||
created_at_utc: datetime
|
||||
updated_at_utc: datetime
|
||||
|
||||
|
||||
class ClientListRow(ClientOut):
|
||||
partner_name: Optional[str] = None
|
||||
branch_name: Optional[str] = None
|
||||
tenant_name: Optional[str] = None
|
||||
|
||||
|
||||
class ClientAuditLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
client_id: int
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
actor_user_id: Optional[int] = None
|
||||
action: str
|
||||
summary: str
|
||||
payload_json: Optional[dict] = None
|
||||
created_at_utc: datetime
|
||||
|
||||
|
||||
class PaginationMeta(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
class ClientFilterOptions(BaseModel):
|
||||
client_types: list[str]
|
||||
client_statuses: list[str]
|
||||
client_categories: list[str]
|
||||
risk_categories: list[str]
|
||||
|
||||
|
||||
class ClientListStats(BaseModel):
|
||||
total: int = 0
|
||||
active: int = 0
|
||||
inactive: int = 0
|
||||
archived: int = 0
|
||||
|
||||
|
||||
class ClientListResponse(BaseModel):
|
||||
rows: list[ClientOut]
|
||||
meta: PaginationMeta
|
||||
stats: ClientListStats | None = None
|
||||
filter_options: ClientFilterOptions | None = None
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.modules.clients.association_admin_service import (
|
||||
ensure_active_association,
|
||||
update_association_fields,
|
||||
)
|
||||
from app.modules.clients.constants import (
|
||||
CLIENT_CATEGORY_OPTIONS,
|
||||
CLIENT_STATUS,
|
||||
CLIENT_TYPES,
|
||||
RISK_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
def _payload_from_schema(data):
|
||||
return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
|
||||
email_clean = (email or '').strip().lower()
|
||||
pw = (portal_password or '').strip()
|
||||
pw2 = (portal_password_confirm or '').strip()
|
||||
if not required and not pw and not pw2:
|
||||
return email_clean, None
|
||||
if not email_clean:
|
||||
raise HTTPException(status_code=400, detail="Email is required to create the client frontend login.")
|
||||
if len(pw) < 8:
|
||||
raise HTTPException(status_code=400, detail="Portal password must be at least 8 characters.")
|
||||
if pw != pw2:
|
||||
raise HTTPException(status_code=400, detail="Portal password and confirm password do not match.")
|
||||
return email_clean, pw
|
||||
|
||||
|
||||
def _sync_client_portal_user(db, *, row, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
email_clean, pw = _ensure_portal_passwords(
|
||||
getattr(row, 'email', None),
|
||||
portal_password,
|
||||
portal_password_confirm,
|
||||
required=bool(portal_password or portal_password_confirm or not getattr(row, 'portal_user_id', None)),
|
||||
)
|
||||
|
||||
if not pw:
|
||||
return row
|
||||
|
||||
existing_user = repository.get_user_by_email(db, email=email_clean, exclude_user_id=getattr(row, 'portal_user_id', None))
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="That email is already used by another login.")
|
||||
|
||||
if row.portal_user_id:
|
||||
user = db.get(repository.User, int(row.portal_user_id))
|
||||
if not user:
|
||||
row = repository.update_client(db, row, {'portal_user_id': None})
|
||||
else:
|
||||
user.email = email_clean
|
||||
user.full_name = (row.client_name or '').strip()
|
||||
user.password_hash = hash_password(pw)
|
||||
user.tenant_id = row.tenant_id
|
||||
user.branch_id = row.branch_id
|
||||
user.is_active = True
|
||||
user.allow_login = True
|
||||
user.is_locked = False
|
||||
user.must_change_password = False
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return row
|
||||
|
||||
if not row.portal_user_id:
|
||||
user = repository.create_portal_user(
|
||||
db,
|
||||
email=email_clean,
|
||||
full_name=row.client_name,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
password=pw,
|
||||
)
|
||||
role = repository.get_role_by_name(db, 'Client')
|
||||
if not role:
|
||||
raise HTTPException(status_code=500, detail='Client role is not available.')
|
||||
repository.ensure_user_role(db, user_id=user.id, role_id=role.id)
|
||||
row = repository.update_client(db, row, {'portal_user_id': user.id})
|
||||
return row
|
||||
|
||||
|
||||
def _validate_scope_for_create(data, scope, actor_user_id):
|
||||
if scope.own_only:
|
||||
data.partner_id = scope.locked_partner_id or actor_user_id
|
||||
if not scope.allow_cross_tenant:
|
||||
data.tenant_id = scope.tenant_id
|
||||
if not scope.allow_cross_branch and scope.branch_id:
|
||||
data.branch_id = scope.branch_id
|
||||
return data
|
||||
|
||||
|
||||
def _validate_scope_for_edit(data, scope, actor_user_id, *, existing_row, current_user_roles):
|
||||
role_names = {str(r).lower() for r in current_user_roles}
|
||||
|
||||
if data.tenant_id is None:
|
||||
data.tenant_id = existing_row.tenant_id
|
||||
if data.branch_id is None:
|
||||
data.branch_id = existing_row.branch_id
|
||||
if data.partner_id is None:
|
||||
data.partner_id = existing_row.partner_id
|
||||
|
||||
if "partner" in role_names and data.partner_id and data.partner_id != actor_user_id:
|
||||
raise HTTPException(status_code=400, detail="Partner users cannot assign clients to another partner.")
|
||||
|
||||
if "consultant" in role_names and data.partner_id and data.partner_id != existing_row.partner_id:
|
||||
raise HTTPException(status_code=400, detail="Consultants cannot assign or change partner mapping.")
|
||||
|
||||
if "firm admin" in role_names:
|
||||
if existing_row.tenant_id != scope.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Firm Admin can only manage clients within own firm.")
|
||||
data.tenant_id = scope.tenant_id
|
||||
if not scope.allow_cross_branch and data.branch_id != scope.branch_id:
|
||||
raise HTTPException(status_code=403, detail="Branch change is not allowed in current scope.")
|
||||
return data
|
||||
|
||||
if "system admin" in role_names:
|
||||
return data
|
||||
|
||||
if scope.own_only:
|
||||
data.partner_id = scope.locked_partner_id or actor_user_id
|
||||
if existing_row.partner_id != actor_user_id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own associated clients.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _write_association_from_client(db, client_row, *, actor_user_id, current_user_roles):
|
||||
roles = {str(r).lower() for r in current_user_roles}
|
||||
ensure_active_association(db, client_row.id)
|
||||
|
||||
if "system admin" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm" if client_row.tenant_id else "self_service_unassigned",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=client_row.partner_id,
|
||||
created_source="system_admin",
|
||||
)
|
||||
|
||||
if "firm admin" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=client_row.partner_id,
|
||||
created_source="firm_admin",
|
||||
)
|
||||
|
||||
if "partner" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=actor_user_id,
|
||||
created_source="partner",
|
||||
)
|
||||
|
||||
if "consultant" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="consultant",
|
||||
consultant_id=actor_user_id,
|
||||
created_source="consultant",
|
||||
)
|
||||
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="self_service_unassigned",
|
||||
created_source="self_service",
|
||||
)
|
||||
|
||||
|
||||
def create_client_service(db, *, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
current_user_roles = current_user_roles or []
|
||||
data = _validate_scope_for_create(data, scope, actor_user_id)
|
||||
|
||||
existing = repository.get_client_by_code(db, tenant_id=data.tenant_id, client_code=data.client_code)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Client code already exists.")
|
||||
|
||||
if data.pan:
|
||||
existing_pan = repository.get_client_by_pan(db, tenant_id=data.tenant_id, pan=data.pan)
|
||||
if existing_pan:
|
||||
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
|
||||
|
||||
if data.gstin:
|
||||
existing_gstin = repository.get_client_by_gstin(db, tenant_id=data.tenant_id, gstin=data.gstin)
|
||||
if existing_gstin:
|
||||
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
row = repository.create_client(db, payload)
|
||||
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
|
||||
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="created",
|
||||
summary="Client created with association sync.",
|
||||
payload_json={"client_id": row.id, "partner_id": row.partner_id},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def update_client_service(db, *, row, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
current_user_roles = current_user_roles or []
|
||||
data = _validate_scope_for_edit(
|
||||
data,
|
||||
scope,
|
||||
actor_user_id,
|
||||
existing_row=row,
|
||||
current_user_roles=current_user_roles,
|
||||
)
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
|
||||
if payload.get("pan"):
|
||||
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
|
||||
if existing_pan and existing_pan.id != row.id:
|
||||
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
|
||||
|
||||
if payload.get("gstin"):
|
||||
existing_gstin = repository.get_client_by_gstin(db, tenant_id=payload["tenant_id"], gstin=payload["gstin"])
|
||||
if existing_gstin and existing_gstin.id != row.id:
|
||||
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
|
||||
|
||||
updated = repository.update_client(db, row, payload)
|
||||
updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
|
||||
_write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=updated.id,
|
||||
tenant_id=updated.tenant_id,
|
||||
branch_id=updated.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="updated",
|
||||
summary="Client updated with association sync.",
|
||||
payload_json={"client_id": updated.id, "partner_id": updated.partner_id},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def get_client_or_404(
|
||||
db,
|
||||
*,
|
||||
client_id: int,
|
||||
tenant_id: int,
|
||||
branch_id: int | None,
|
||||
allow_cross_branch: bool,
|
||||
allow_all_clients: bool = False,
|
||||
):
|
||||
row = repository.get_client_by_id(db, client_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
if not allow_all_clients and row.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found in current tenant.")
|
||||
if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found in current branch.")
|
||||
return row
|
||||
|
||||
|
||||
def list_clients_payload(db, **kwargs):
|
||||
return repository.list_clients(db, **kwargs)
|
||||
|
||||
|
||||
def list_client_audit_logs(db, *, row, limit: int = 50):
|
||||
return repository.list_audit_logs(db, client_id=row.id, limit=limit)
|
||||
|
||||
|
||||
def deactivate_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "inactive"})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="deactivated",
|
||||
summary="Client deactivated.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def activate_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "active"})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="activated",
|
||||
summary="Client activated.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def archive_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "archived", "is_archived": True})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="archived",
|
||||
summary="Client archived.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def restore_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "active", "is_archived": False})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="restored",
|
||||
summary="Client restored from archive.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def export_clients_csv(payload: dict) -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"status",
|
||||
"pan",
|
||||
"gstin",
|
||||
"partner",
|
||||
"association_type",
|
||||
"association_source",
|
||||
]
|
||||
)
|
||||
for row in payload.get("rows", []):
|
||||
writer.writerow(
|
||||
[
|
||||
row.get("client_code"),
|
||||
row.get("client_name"),
|
||||
row.get("client_type"),
|
||||
row.get("status"),
|
||||
row.get("pan"),
|
||||
row.get("gstin"),
|
||||
row.get("partner_name") or row.get("effective_partner_id"),
|
||||
row.get("association_type"),
|
||||
row.get("assoc_created_source"),
|
||||
]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def get_filter_options():
|
||||
return {
|
||||
"client_types": CLIENT_TYPES,
|
||||
"client_statuses": CLIENT_STATUS,
|
||||
"client_categories": CLIENT_CATEGORY_OPTIONS,
|
||||
"risk_categories": RISK_CATEGORIES,
|
||||
}
|
||||
|
||||
|
||||
SELF_SERVICE_EDITABLE_FIELDS = {
|
||||
"client_name",
|
||||
"trade_name",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"mobile",
|
||||
"alternate_mobile",
|
||||
"email",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"notes",
|
||||
}
|
||||
|
||||
|
||||
def update_client_self_profile_service(db, *, row, data, current_user):
|
||||
payload = _payload_from_schema(data)
|
||||
payload = {key: value for key, value in payload.items() if key in SELF_SERVICE_EDITABLE_FIELDS}
|
||||
|
||||
new_email = (payload.get("email") or "").strip().lower()
|
||||
if new_email:
|
||||
existing_user = repository.get_user_by_email(db, email=new_email, exclude_user_id=int(current_user.id))
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="That email is already used by another login.")
|
||||
|
||||
updated = repository.update_client(db, row, payload)
|
||||
|
||||
if new_email and new_email != (getattr(current_user, "email", "") or "").strip().lower():
|
||||
current_user.email = new_email
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=updated.id,
|
||||
tenant_id=updated.tenant_id,
|
||||
branch_id=updated.branch_id,
|
||||
actor_user_id=current_user.id,
|
||||
action="client_self_profile_updated",
|
||||
summary="Client updated own contact profile.",
|
||||
payload_json={"fields": sorted(payload.keys())},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def reset_client_portal_password_service(db, *, current_user, new_password: str):
|
||||
if len((new_password or "").strip()) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
||||
current_user.password_hash = hash_password(new_password.strip())
|
||||
current_user.must_change_password = False
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="mb-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<div class="flex min-w-max items-center gap-2">
|
||||
{% set path = request.url.path %}
|
||||
<a href="/client/dashboard" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/client/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
|
||||
<a href="/client/compliance" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/compliance') or path.startswith('/client/engagements') else 'text-slate-700 hover:bg-slate-100' }}">My Compliance</a>
|
||||
<a href="/client/documents" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/documents') else 'text-slate-700 hover:bg-slate-100' }}">My Documents</a>
|
||||
<a href="/client/messages" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/messages') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
|
||||
<a href="/client/billing" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/billing') else 'text-slate-700 hover:bg-slate-100' }}">My Bills</a>
|
||||
<a href="/client/profile" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
|
||||
<a href="/alerts" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/alerts' else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">Add Client</h2><p class="text-sm text-slate-500">Create a validated client master with ownership and branch-safe rules.</p></div>{% if form_errors %}<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 shadow-soft">{% for err in form_errors %}<div>{{ err }}</div>{% endfor %}</div>{% endif %}<form method="post" action="/clients" class="space-y-6"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">{% include "modules/clients/templates/clients/partials/form.html" %}<div class="flex justify-end gap-3"><a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Client</button></div></form></div>{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div><h2 class="text-2xl font-semibold text-slate-900">My Compliance</h2><p class="text-sm text-slate-500">Service-wise status and pending actions visible to you.</p></div>
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Pending from Client</div><div class="mt-2 text-2xl font-semibold">{{ pending_from_client or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">With Firm</div><div class="mt-2 text-2xl font-semibold">{{ with_firm or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Clarification Required</div><div class="mt-2 text-2xl font-semibold">{{ clarification_required or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Completed</div><div class="mt-2 text-2xl font-semibold">{{ completed_engagements or 0 }}</div></div>
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{% for row in engagements %}
|
||||
<a href="/work/engagements/{{ row.id }}" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft hover:bg-slate-50">
|
||||
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</h3><p class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</p></div><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ row.status|replace('_',' ')|title }}</span></div>
|
||||
<div class="mt-4 grid gap-3 text-sm md:grid-cols-3"><div><div class="text-xs text-slate-500">Due Date</div><div>{{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div></div><div><div class="text-xs text-slate-500">Firm Contact</div><div>{{ row.assigned_manager.full_name if row.assigned_manager else (row.assigned_partner.full_name if row.assigned_partner else 'Firm team') }}</div></div><div><div class="text-xs text-slate-500">Status</div><div>{{ row.status|replace('_',' ')|title }}</div></div></div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-sm text-slate-500 lg:col-span-2">No active compliance services are assigned yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">{{ row.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ row.client_code }} • {{ row.client_type }} • {{ row.status|title }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{% if can_edit %}
|
||||
<a href="/clients/{{ row.id }}/edit"
|
||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_activate and row.status != 'active' and row.status != 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/activate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||
Activate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_deactivate and row.status == 'active' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/deactivate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-700 hover:bg-rose-50">
|
||||
Deactivate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_archive and row.status != 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/archive">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50">
|
||||
Archive
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_restore and row.status == 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/restore">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-sky-300 px-4 py-2 text-sm font-medium text-sky-700 hover:bg-sky-50">
|
||||
Restore
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Profile</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{% for label, value in [
|
||||
('Trade Name', row.trade_name),
|
||||
('PAN', row.pan),
|
||||
('GSTIN', row.gstin),
|
||||
('TAN', row.tan),
|
||||
('Contact Person', row.contact_person_name),
|
||||
('Designation', row.contact_person_designation),
|
||||
('Mobile', row.mobile),
|
||||
('Email', row.email)
|
||||
] %}
|
||||
<div class="rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
|
||||
<div class="mt-1 text-sm text-slate-800">{{ value or '-' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Association</h3>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div><span class="font-medium">Type:</span> {{ row.association_type or 'legacy_firm' }}</div>
|
||||
<div><span class="font-medium">Source:</span> {{ row.assoc_created_source or 'legacy' }}</div>
|
||||
<div><span class="font-medium">Audit Firm:</span> {{ row.assoc_firm_tenant_id or row.tenant_id or '-' }}</div>
|
||||
<div><span class="font-medium">Branch:</span> {{ row.branch_id or '-' }}</div>
|
||||
<div><span class="font-medium">Partner:</span> {{ row.assoc_partner_user_id or row.partner_id or '-' }}</div>
|
||||
<div><span class="font-medium">Default Review Partner:</span> {{ row.default_review_partner_user_id or '-' }}</div>
|
||||
<div><span class="font-medium">Consultant:</span> {{ row.assoc_consultant_id or '-' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div><h2 class="text-2xl font-semibold text-slate-900">My Documents</h2><p class="text-sm text-slate-500">View documents shared by your audit firm.</p></div>
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in engagement_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else '-' }}</td><td class="px-4 py-3 text-slate-600">{{ doc.document_type }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No engagement documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Permanent Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Category</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in permanent_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.category }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/permanent-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">No permanent documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Edit Client</h2>
|
||||
<p class="text-sm text-slate-500">B5 role-aware edit flow.</p>
|
||||
</div>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4">
|
||||
<ul class="list-disc pl-5 text-sm text-rose-700">
|
||||
{% for err in form_errors %}<li>{{ err }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/clients/{{ row.id }}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% include "modules/clients/templates/clients/partials/form.html" %}
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/clients/{{ row.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"><div><h2 class="text-2xl font-semibold text-slate-900">{{ engagement.catalogue.service_name if engagement.catalogue else 'Engagement' }}</h2><p class="text-sm text-slate-500">FY {{ engagement.financial_year }}{% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %} • Due {{ engagement.current_due_date.strftime('%d-%m-%Y') if engagement.current_due_date else '-' }}</p></div><a href="/client/compliance" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Compliance</a></div>
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Task / Action Status</h3><div class="mt-4 space-y-3">{% for task in tasks %}<details class="rounded-2xl border border-slate-200 p-4" {% if loop.first %}open{% endif %}><summary class="cursor-pointer list-none"><div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"><div><div class="font-semibold text-slate-900">{{ task.task_name }}</div><div class="text-xs text-slate-500">{{ task.description or '' }}</div></div><span class="w-fit rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ task.status|replace('_',' ')|title }}</span></div></summary><div class="mt-4 border-t border-slate-100 pt-4"><form method="post" action="/client/tasks/{{ task.id }}/reply" class="space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="block text-sm font-medium text-slate-700">Reply / clarification for firm</label><textarea name="message" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type your clarification, confirmation or query for the firm..."></textarea><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></form></div></details>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No task details available.</div>{% endfor %}</div></div>
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-4 space-y-3">{% for note in comments %}<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="flex justify-between gap-3"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-2 whitespace-pre-line text-slate-700">{{ note.message }}</p></div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No visible communication yet.</div>{% endfor %}</div></div>
|
||||
</section>
|
||||
<aside class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 space-y-3">{% for doc in documents %}{% set ver = doc.versions[0] if doc.versions else None %}<div class="rounded-2xl border border-slate-200 p-4"><div class="font-semibold text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="mt-3 inline-flex rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Download</a>{% endif %}</div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No documents uploaded for this engagement yet.</div>{% endfor %}</div></aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Import Clients</h2>
|
||||
<p class="text-sm text-slate-500">Bulk upload clients for the active audit firm with partner validation.</p>
|
||||
</div>
|
||||
<a href="/clients/import/template" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<div class="font-semibold text-slate-900">Active Audit Firm</div>
|
||||
<div class="mt-1">{{ current_tenant.name if current_tenant else scope.tenant_id }} (ID: {{ scope.tenant_id }})</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<div class="font-semibold text-slate-900">Logged-in uploader</div>
|
||||
<div class="mt-1">{{ current_user.full_name or current_user.email }} — User ID {{ current_user.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">
|
||||
Template includes <strong>uploader_user_id</strong>, <strong>firm_tenant_id</strong>, and <strong>partner_user_id</strong>.
|
||||
Validation checks that uploader_user_id matches the logged-in user, firm_tenant_id matches the active audit firm, and partner_user_id belongs to an active Partner in that same audit firm.
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Partners available in this audit firm</div>
|
||||
<div class="mt-2 text-sm text-slate-600">
|
||||
{% for p in partners %}
|
||||
<div>{{ p.id }} — {{ p.full_name or p.email }}</div>
|
||||
{% else %}
|
||||
<div>No active partners found for this audit firm.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if import_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">
|
||||
{% for err in import_errors %}<div>{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/clients/import/preview" enctype="multipart/form-data" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Excel file</label>
|
||||
<input type="file" name="excel_file" accept=".xlsx" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Validate File</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Import Clients Preview</h2>
|
||||
<p class="text-sm text-slate-500">Review validation result before final import.</p>
|
||||
</div>
|
||||
|
||||
{% if import_result is defined and import_result %}
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">Created {{ import_result.created|length }} clients.</div>
|
||||
{% if import_result.created %}
|
||||
<div class="rounded-xl border border-slate-200 p-4 text-sm">
|
||||
{% for row in import_result.created %}<div>{{ row.client_code }} — {{ row.client_name }} (ID {{ row.id }})</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if import_result.failures %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
{% for err in import_result.failures %}<div>Row {{ err.row_number }}: {{ err.message }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-end"><a href="/clients" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Back to Clients</a></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Valid rows</h3>
|
||||
<div class="mt-3 text-sm text-slate-600">{{ preview.valid_rows|length }} of {{ preview.total_rows }} rows are ready to import.</div>
|
||||
<div class="mt-4 max-h-[28rem] overflow-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-2 text-left">Row</th><th class="px-4 py-2 text-left">Audit Firm ID</th><th class="px-4 py-2 text-left">Partner</th><th class="px-4 py-2 text-left">Client Code</th><th class="px-4 py-2 text-left">Client Name</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for item in preview.valid_rows %}
|
||||
<tr><td class="px-4 py-2">{{ item.row_number }}</td><td class="px-4 py-2">{{ item.tenant_id }}</td><td class="px-4 py-2">{{ item.partner_id }}</td><td class="px-4 py-2">{{ item.client_payload.client_code }}</td><td class="px-4 py-2">{{ item.client_payload.client_name }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No valid rows found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Validation errors</h3>
|
||||
<div class="mt-3 text-sm text-slate-600">{{ preview.errors|length }} rows have issues.</div>
|
||||
<div class="mt-4 max-h-[28rem] space-y-3 overflow-auto">
|
||||
{% for err in preview.errors %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
<div class="font-semibold">Row {{ err.row_number }}</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">{% for msg in err.messages %}<li>{{ msg }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">No validation errors found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/clients/import" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if preview.valid_rows %}
|
||||
<form method="post" action="/clients/import/commit">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="preview_payload" hidden>{{ preview_payload }}</textarea>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Import {{ preview.valid_rows|length }} Valid Rows</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Clients</h2>
|
||||
<p class="text-sm text-slate-500">Association-aware list view.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
{% if can_export %}
|
||||
<a href="/clients/export?q={{ q }}&status={{ status }}&client_type={{ client_type }}&include_archived={{ include_archived }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
|
||||
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Export CSV
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_import %}
|
||||
<a href="/clients/import"
|
||||
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Import Clients
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create %}
|
||||
<a href="/clients/new"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Add Client
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "modules/clients/templates/clients/partials/table.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">My Messages</h2><p class="text-sm text-slate-500">Client-visible communications and clarifications from your firm.</p></div><section class="rounded-2xl bg-white p-6 shadow-soft"><div class="space-y-4">{% for note in comments %}<article class="rounded-2xl border border-slate-200 p-4"><div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"><div><div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ note.comment_type|replace('_',' ')|title }}</div><h3 class="mt-1 font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</h3><div class="text-xs text-slate-500">{{ note.subscription.catalogue.service_name if note.subscription and note.subscription.catalogue else '' }}</div></div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-3 whitespace-pre-line text-sm leading-6 text-slate-700">{{ note.message }}</p><div class="mt-3 text-xs text-slate-500">From: {% if note.created_by %}{{ note.created_by.full_name or note.created_by.email }}{% else %}Firm team{% endif %}</div>{% if note.task %}<a href="/client/engagements/{{ note.task.subscription_id }}" class="mt-3 inline-flex text-sm font-semibold text-brand-700 hover:underline">Open related work</a>{% endif %}</article>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No messages found.</div>{% endfor %}</div></section></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">When</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Summary</th></tr></thead><tbody class="divide-y divide-slate-100">{% for log in audit_logs %}<tr><td class="px-4 py-3 text-sm text-slate-700">{{ log.created_at_utc }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.action }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.summary }}</td></tr>{% else %}<tr><td colspan="3" class="px-4 py-6 text-center text-sm text-slate-500">No audit entries yet.</td></tr>{% endfor %}</tbody></table></div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{% for label, value in [('GST', row.gst_applicable),('Income Tax', row.income_tax_applicable),('TDS', row.tds_applicable),('ROC', row.roc_applicable),('Audit', row.audit_applicable),('PF', row.pf_applicable),('ESI', row.esi_applicable),('Professional Tax', row.professional_tax_applicable),('Payroll', row.payroll_applicable),('MSME', row.msme_applicable),('Import / Export', row.import_export_applicable)] %}<div class="rounded-xl border border-slate-200 px-3 py-3 text-sm"><div class="font-medium text-slate-700">{{ label }}</div><div class="mt-1 {% if value %}text-emerald-700{% else %}text-slate-500{% endif %}">{% if value %}Applicable{% else %}Not Applicable{% endif %}</div></div>{% endfor %}</div>
|
||||
@@ -0,0 +1,372 @@
|
||||
{% set is_edit = row is defined and row %}
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Basic Profile</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Code</label>
|
||||
<input name="client_code" value="{{ form_data.client_code or (row.client_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if is_edit %}readonly{% endif %}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Name</label>
|
||||
<input name="client_name" value="{{ form_data.client_name or (row.client_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ form_data.trade_name or (row.trade_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Type</label>
|
||||
<select name="client_type" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in client_types %}
|
||||
<option value="{{ opt }}" {% if (form_data.client_type or (row.client_type if is_edit else 'Other')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ form_data.pan or (row.pan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ form_data.gstin or (row.gstin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">TAN</label>
|
||||
<input name="tan" value="{{ form_data.tan or (row.tan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">CIN / LLPIN</label>
|
||||
<input name="cin_llpin" value="{{ form_data.cin_llpin or (row.cin_llpin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">MSME Number</label>
|
||||
<input name="msme_no" value="{{ form_data.msme_no or (row.msme_no if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">IEC Code</label>
|
||||
<input name="iec_code" value="{{ form_data.iec_code or (row.iec_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ form_data.mobile or (row.mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
|
||||
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or (row.alternate_mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email (used for frontend login)</label>
|
||||
<input name="email" value="{{ form_data.email or (row.email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
|
||||
<input name="alternate_email" value="{{ form_data.alternate_email or (row.alternate_email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contact Person</label>
|
||||
<input name="contact_person_name" value="{{ form_data.contact_person_name or (row.contact_person_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Designation</label>
|
||||
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or (row.contact_person_designation if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Category</label>
|
||||
<select name="client_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in client_categories %}
|
||||
<option value="{{ opt }}" {% if (form_data.client_category or (row.client_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Risk Category</label>
|
||||
<select name="risk_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in risk_categories %}
|
||||
<option value="{{ opt }}" {% if (form_data.risk_category or (row.risk_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Onboarding Date</label>
|
||||
<input type="date" name="onboarding_date" value="{{ form_data.onboarding_date or (row.onboarding_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Closing Date</label>
|
||||
<input type="date" name="closing_date" value="{{ form_data.closing_date or (row.closing_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ form_data.address_line_1 or (row.address_line_1 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ form_data.address_line_2 or (row.address_line_2 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ form_data.city or (row.city if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ form_data.state or (row.state if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ form_data.pincode or (row.pincode if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ form_data.country or (row.country if is_edit else 'India') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or (row.notes if is_edit else '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Assignment & Scope</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Engagement Mode</label>
|
||||
<select name="engagement_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in ['internal_managed','self_tracked','hybrid'] %}
|
||||
<option value="{{ opt }}" {% if (form_data.engagement_mode or (row.engagement_mode if is_edit else 'internal_managed')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if form_mode == 'firm_admin' %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
|
||||
<div class="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm text-slate-700">
|
||||
{% for t in form_options.tenants %}
|
||||
{{ t.name }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for b in form_options.branches %}
|
||||
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Partner</label>
|
||||
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Partner --</option>
|
||||
{% for p in form_options.partners %}
|
||||
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'system_admin' %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
|
||||
<select name="tenant_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for t in form_options.tenants %}
|
||||
<option value="{{ t.id }}" {% if (form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id)) == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Branch --</option>
|
||||
{% for b in form_options.branches %}
|
||||
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>
|
||||
{{ b.name }}{% if b.tenant_name %} ({{ b.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Partner</label>
|
||||
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Partner --</option>
|
||||
{% for p in form_options.partners %}
|
||||
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>
|
||||
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'partner' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
Partner assignment is locked to your own user.
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
|
||||
<input type="hidden" name="partner_id" value="{{ current_user.id }}">
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'consultant' %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Consultant users cannot assign or reassign partner mappings.
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
|
||||
<input type="hidden" name="partner_id" value="{{ form_data.partner_id or (row.partner_id if is_edit else '') }}">
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'self_service' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
This client is currently unassigned. Initial association to firm, branch, and partner must be done by System Admin.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Default Review Partner</label>
|
||||
<select name="default_review_partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Not required / Select later --</option>
|
||||
{% for p in form_options.review_partners or [] %}
|
||||
<option value="{{ p.id }}" {% if (form_data.default_review_partner_user_id or (row.default_review_partner_user_id if is_edit else None)) == p.id %}selected{% endif %}>
|
||||
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Used automatically for assurance engagements only when the audit firm is a partnership firm.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Status</label>
|
||||
<select name="status" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in client_statuses %}
|
||||
<option value="{{ opt }}" {% if (form_data.status or (row.status if is_edit else 'active')) == opt %}selected{% endif %}>{{ opt|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h4 class="mb-3 text-sm font-semibold text-slate-900">Client Frontend Login</h4>
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 p-3 text-xs text-sky-800">
|
||||
{% if is_edit and row.portal_user_id %}
|
||||
Linked portal user already exists. Leave password blank to keep the current password, or enter a new password to reset it.
|
||||
{% elif is_edit %}
|
||||
This existing client does not yet have a linked login. Enter email and password below to create the client login now.
|
||||
{% else %}
|
||||
Creating a client will also create a frontend login using the client email and password below.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}New Password{% else %}Password{% endif %}</label>
|
||||
<input name="portal_password" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}Confirm New Password{% else %}Confirm Password{% endif %}</label>
|
||||
<input name="portal_password_confirm" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
|
||||
</div>
|
||||
|
||||
{% if is_edit and row.portal_user_id %}
|
||||
<div class="text-xs text-slate-500">
|
||||
Portal user id linked: {{ row.portal_user_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h4 class="mb-3 text-sm font-semibold text-slate-900">Compliance Applicability</h4>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="gst_applicable" value="1" {% if form_data.gst_applicable or (row.gst_applicable if is_edit else false) %}checked{% endif %}>
|
||||
GST Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="income_tax_applicable" value="1" {% if form_data.income_tax_applicable or (row.income_tax_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Income Tax Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="tds_applicable" value="1" {% if form_data.tds_applicable or (row.tds_applicable if is_edit else false) %}checked{% endif %}>
|
||||
TDS Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="roc_applicable" value="1" {% if form_data.roc_applicable or (row.roc_applicable if is_edit else false) %}checked{% endif %}>
|
||||
ROC Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="audit_applicable" value="1" {% if form_data.audit_applicable or (row.audit_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Audit Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="pf_applicable" value="1" {% if form_data.pf_applicable or (row.pf_applicable if is_edit else false) %}checked{% endif %}>
|
||||
PF Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="esi_applicable" value="1" {% if form_data.esi_applicable or (row.esi_applicable if is_edit else false) %}checked{% endif %}>
|
||||
ESI Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="professional_tax_applicable" value="1" {% if form_data.professional_tax_applicable or (row.professional_tax_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Professional Tax Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="payroll_applicable" value="1" {% if form_data.payroll_applicable or (row.payroll_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Payroll Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="msme_applicable" value="1" {% if form_data.msme_applicable or (row.msme_applicable if is_edit else false) %}checked{% endif %}>
|
||||
MSME Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="import_export_applicable" value="1" {% if form_data.import_export_applicable or (row.import_export_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Import / Export Applicable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Association</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.client_code }}</td><td class="px-4 py-3 text-sm text-slate-700"><div class="font-medium">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.pan or row.gstin or '-' }}</div></td><td class="px-4 py-3 text-sm text-slate-700"><div>{{ row.association_type or 'legacy_firm' }}</div><div class="text-xs text-slate-500">{{ row.assoc_created_source or 'legacy' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{{ row.partner_name or row.effective_partner_id or '-' }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}</td><td class="px-4 py-3 text-sm">{% if row.status == 'active' %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% elif row.status == 'archived' %}<span class="rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">Archived</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No clients found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">My Compliance & Firm Communication</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track your compliance status, pending actions, required documents, messages and firm updates.</p>
|
||||
</div>
|
||||
{% if client_row %}<a href="/client/profile" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Update My Profile</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not client_row %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 shadow-soft">
|
||||
We could not find a client master linked to your login email in the current audit firm. Please contact your firm admin to map this login to the correct client record.
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Active Compliance</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ total_engagements or 0 }}</div><div class="mt-1 text-xs text-slate-500">Services / filings</div></a>
|
||||
<a href="/client/compliance" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Pending Action</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div><div class="mt-1 text-xs text-amber-700">Required from you</div></a>
|
||||
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">With Firm</div><div class="mt-2 text-3xl font-semibold text-brand-700">{{ with_firm or 0 }}</div><div class="mt-1 text-xs text-slate-500">Being handled</div></a>
|
||||
<a href="/client/documents" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Documents</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ recent_documents|length if recent_documents else 0 }}</div><div class="mt-1 text-xs text-slate-500">Recent uploads</div></a>
|
||||
<a href="/client/billing" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Outstanding Bills</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-amber-700">{{ billing_open_count or 0 }} open bill(s)</div></a>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<div class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><div><h3 class="text-lg font-semibold text-slate-900">Compliance Status</h3><p class="mt-1 text-sm text-slate-500">Simple client-facing status of your active services.</p></div><a href="/client/compliance" class="af-btn af-btn-primary">View All</a></div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-4 text-sm">
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3"><div class="text-amber-700">Pending from You</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3"><div class="text-brand-700">With Firm</div><div class="mt-1 text-2xl font-semibold text-brand-700">{{ with_firm or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3"><div class="text-blue-700">Clarification</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ clarification_required or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3"><div class="text-emerald-700">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ completed_engagements or 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for row in due_soon_engagements[:6] %}
|
||||
<a href="/client/engagements/{{ row.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div><div class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</div><div class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</div></div>
|
||||
<div class="text-sm text-slate-600">Due: {{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No active compliance items found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Latest Messages</h3><a href="/client/messages" class="text-sm font-semibold text-brand-700 hover:underline">View all</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for note in client_visible_comments[:5] %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><p class="mt-2 text-slate-700">{{ note.message }}</p><div class="mt-2 text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No messages yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">My Client Profile</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-semibold">{{ client_row.client_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN / GSTIN</div><div>{{ client_row.pan or '-' }}{% if client_row.gstin %} / {{ client_row.gstin }}{% endif %}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Firm Contact</div><div>{{ client_row.partner_name or 'Firm team' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Branch</div><div>{{ client_row.branch_name or '-' }}</div></div>
|
||||
</div>
|
||||
<a href="/client/profile" class="mt-5 inline-flex af-btn af-btn-secondary">Update Profile</a>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/client/compliance" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View My Compliance</a>
|
||||
<a href="/client/documents" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Upload / View Documents</a>
|
||||
<a href="/client/messages" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Messages from Firm</a>
|
||||
<a href="/client/billing" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View Bills & Receipts</a>
|
||||
{% if billing_latest_due_invoice %}<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 font-semibold text-amber-700 hover:bg-amber-100">Pay Latest Due</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Edit My Profile</h2>
|
||||
<p class="text-sm text-slate-500">You can update contact and communication details here. PAN, GSTIN and other compliance identity fields stay read-only.</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="/change-password" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Change Password</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
{% for error in form_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/client/profile" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Read-only compliance identity</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">PAN</div><div class="mt-1 text-slate-900">{{ client_row.pan or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">GSTIN</div><div class="mt-1 text-slate-900">{{ client_row.gstin or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">TAN</div><div class="mt-1 text-slate-900">{{ client_row.tan or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">CIN / LLPIN</div><div class="mt-1 text-slate-900">{{ client_row.cin_llpin or '-' }}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Editable profile details</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Name</label>
|
||||
<input name="client_name" value="{{ form_data.client_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ form_data.trade_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contact Person Name</label>
|
||||
<input name="contact_person_name" value="{{ form_data.contact_person_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Designation</label>
|
||||
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ form_data.mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
|
||||
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Login Email</label>
|
||||
<input type="email" name="email" value="{{ form_data.email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">If you change this, your next login will use the new email.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
|
||||
<input type="email" name="alternate_email" value="{{ form_data.alternate_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ form_data.address_line_1 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ form_data.address_line_2 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ form_data.city or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ form_data.state or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ form_data.pincode or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ form_data.country or 'India' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Save Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
|
||||
PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
|
||||
GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
|
||||
TAN_RE = re.compile(r"^[A-Z]{4}[0-9]{5}[A-Z]$")
|
||||
MOBILE_RE = re.compile(r"^[6-9][0-9]{9}$")
|
||||
PIN_RE = re.compile(r"^[0-9]{6}$")
|
||||
|
||||
def normalize_text(value):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
def normalize_upper(value):
|
||||
value = normalize_text(value)
|
||||
return value.upper() if value else None
|
||||
|
||||
def build_csv(rows, headers):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
return output.getvalue()
|
||||
Reference in New Issue
Block a user