Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
from .api import router as api_router
from .ui import router as ui_router
__all__ = ["api_router", "ui_router"]
+76
View File
@@ -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
+178
View File
@@ -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
+18
View File
@@ -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',
}
+120
View File
@@ -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,
)
+44
View File
@@ -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",
}
+36
View File
@@ -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",
)
+281
View File
@@ -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}
+85
View File
@@ -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)
+15
View File
@@ -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",
}
+244
View File
@@ -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,
}
+523
View File
@@ -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
+386
View File
@@ -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
+452
View File
@@ -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
+28
View File
@@ -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()