diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py
index b884f48..30c9545 100644
--- a/app/modules/documents/ui.py
+++ b/app/modules/documents/ui.py
@@ -17,6 +17,7 @@ from sqlalchemy.orm import joinedload
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
+from app.core.security.jwt_tokens import decode_token, encode_access_token
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
@@ -33,6 +34,7 @@ from app.modules.documents.services import (
cleanup_completed_permanent_storage_job_vps_stage,
retry_verified_vps_cleanup_for_node,
authenticate_storage_node,
+ authenticate_storage_agent,
create_branch_storage_node,
generate_storage_secret,
hash_storage_secret,
@@ -1578,6 +1580,122 @@ def download_storage_node_env(request: Request, csrf_token: str = Form(...)):
return RedirectResponse(url="/documents/storage-nodes?error=env_download_disabled", status_code=303)
+@router.post("/storage-nodes/{node_id}/download-new-workstation")
+def download_new_workstation_agent(
+ request: Request,
+ node_id: int,
+ storage_root_path: str = Form(r"D:\AuditFirmStorage"),
+ csrf_token: str = Form(...),
+):
+ """Download a short-lived enrollment package without rotating existing agent credentials."""
+ validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, response = _require_user(request, db, "documents.upload")
+ if response:
+ return response
+ scope = build_document_scope(request, db, user)
+ if not _can_manage_branch_storage(scope):
+ return _redirect_denied()
+ node = db.get(BranchStorageNode, node_id)
+ if not _node_allowed_for_storage_scope(node, user, scope):
+ return _redirect_denied()
+ token = encode_access_token(
+ {
+ "purpose": "workstation_enrollment",
+ "node_id": int(node.id),
+ "node_code": node.node_code,
+ "tenant_id": int(node.tenant_id),
+ "branch_id": int(node.branch_id) if node.branch_id is not None else None,
+ },
+ expires_minutes=30,
+ )
+ env_text = build_agent_env(
+ erp_base_url=str(request.base_url).rstrip("/"),
+ node_code=node.node_code,
+ node_secret="",
+ enrollment_token=token,
+ storage_root=_effective_storage_root(node, storage_root_path),
+ tenant_id=node.tenant_id,
+ branch_id=node.branch_id,
+ )
+ package = build_preconfigured_agent_zip(
+ env_text=env_text, include_admin_readme=bool(scope.is_system_admin)
+ )
+ filename = _agent_download_filename(f"{node.node_code}-NEW-WORKSTATION", ".zip")
+ return Response(
+ package,
+ media_type="application/zip",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+ finally:
+ db.close()
+
+
+@router.post("/storage-agent/enroll-workstation")
+async def enroll_workstation(request: Request):
+ """Exchange a short-lived enrollment token for a workstation-only agent secret."""
+ payload = await request.json()
+ token = str(payload.get("enrollment_token") or "").strip()
+ workstation_payload = payload.get("workstation") if isinstance(payload.get("workstation"), dict) else {}
+ try:
+ claims = decode_token(token)
+ except Exception:
+ return JSONResponse({"ok": False, "error": "invalid_or_expired_enrollment_token"}, status_code=401)
+ if claims.get("purpose") != "workstation_enrollment":
+ return JSONResponse({"ok": False, "error": "invalid_enrollment_token_type"}, status_code=401)
+ instance_id = str(workstation_payload.get("agent_instance_id") or "").strip()
+ fingerprint = str(workstation_payload.get("machine_fingerprint") or "").strip()
+ machine_name = str(workstation_payload.get("machine_name") or "").strip()
+ if not instance_id or not fingerprint or not machine_name:
+ return JSONResponse({"ok": False, "error": "workstation_identity_required"}, status_code=400)
+ db = CommonSessionLocal()
+ try:
+ node = db.get(BranchStorageNode, int(claims.get("node_id") or 0))
+ if (
+ not node
+ or not node.is_active
+ or node.node_code != claims.get("node_code")
+ or int(node.tenant_id) != int(claims.get("tenant_id") or 0)
+ ):
+ return JSONResponse({"ok": False, "error": "enrollment_node_unavailable"}, status_code=403)
+ row = db.execute(
+ select(ERPWorkstationAgent).where(
+ ERPWorkstationAgent.storage_node_id == node.id,
+ ERPWorkstationAgent.agent_instance_id == instance_id,
+ )
+ ).scalar_one_or_none()
+ if row is None:
+ row = ERPWorkstationAgent(
+ tenant_id=node.tenant_id,
+ branch_id=node.branch_id,
+ storage_node_id=node.id,
+ agent_instance_id=instance_id,
+ machine_fingerprint=fingerprint,
+ machine_name=machine_name[:200],
+ )
+ db.add(row)
+ secret = generate_storage_secret()
+ row.workstation_secret_hash = hash_storage_secret(secret)
+ row.machine_fingerprint = fingerprint
+ row.machine_name = machine_name[:200]
+ row.platform_name = str(workstation_payload.get("platform_name") or "")[:120] or None
+ row.agent_version = str(workstation_payload.get("agent_version") or ERP_LOCAL_AGENT_VERSION)[:40] or None
+ row.is_active = True
+ row.status = "enrolled"
+ row.last_seen_at_utc = datetime.now(timezone.utc)
+ row.last_seen_ip = request.client.host if request.client else None
+ db.commit()
+ return {
+ "ok": True,
+ "node_code": node.node_code,
+ "agent_instance_id": instance_id,
+ "workstation_secret": secret,
+ }
+ finally:
+ db.close()
+
+
@router.post("/storage-nodes/download-agent-package")
def download_preconfigured_storage_agent(
request: Request,
@@ -1694,7 +1812,10 @@ def storage_jobs(request: Request, status: str = ""):
def _agent_auth(db, request: Request, x_node_code: str | None, x_node_secret: str | None):
- node = authenticate_storage_node(db, x_node_code, x_node_secret, request=request)
+ instance_id = (request.headers.get("x-agent-instance-id") or "").strip()
+ node = authenticate_storage_agent(
+ db, x_node_code, x_node_secret, agent_instance_id=instance_id, request=request
+ )
if not node:
return None, JSONResponse({"ok": False, "error": "invalid_storage_node_credentials"}, status_code=401)
return node, None
@@ -1942,11 +2063,12 @@ async def storage_agent_tunnel(websocket: WebSocket):
"""
node_code = websocket.query_params.get("node_code") or websocket.headers.get("x-node-code")
node_secret = websocket.query_params.get("node_secret") or websocket.headers.get("x-node-secret")
+ agent_instance_id = websocket.query_params.get("agent_instance_id") or websocket.headers.get("x-agent-instance-id")
await websocket.accept()
db = CommonSessionLocal()
try:
- node = authenticate_storage_node(db, node_code, node_secret, request=None)
+ node = authenticate_storage_agent(db, node_code, node_secret, agent_instance_id=agent_instance_id, request=None)
if not node:
await websocket.send_json({"ok": False, "type": "error", "error": "invalid_storage_node_credentials"})
await websocket.close(code=1008)
@@ -1971,7 +2093,7 @@ async def storage_agent_tunnel(websocket: WebSocket):
db = CommonSessionLocal()
try:
- node = authenticate_storage_node(db, node_code, node_secret, request=None)
+ node = authenticate_storage_agent(db, node_code, node_secret, agent_instance_id=agent_instance_id, request=None)
if not node:
await websocket.send_json({"ok": False, "type": "error", "error": "node_deactivated_or_invalid"})
await websocket.close(code=1008)
diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py
index 0380cde..a09e78e 100644
--- a/app/modules/employees/service.py
+++ b/app/modules/employees/service.py
@@ -23,7 +23,7 @@ from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
from app.modules.documents.models import EngagementDocument
-from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement
+from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement, FirmServiceTaskTemplate, ServiceTaskCategory
from app.modules.registrations.models import ClientRegistration, RegistrationType
from app.modules.services.engagement_resources import build_engagement_resource_context
from app.modules.services.execution import (
@@ -152,11 +152,18 @@ def list_employees(
q: str = "",
include_inactive: bool = False,
link_status: str = "all",
+ status_filter: str | None = None,
) -> list[Employee]:
stmt = select(Employee).where(Employee.tenant_id == scope.tenant_id)
if scope.branch_id is not None:
stmt = stmt.where(Employee.branch_id == scope.branch_id)
- if not include_inactive:
+
+ normalized_status = (status_filter or "").strip().lower()
+ if normalized_status in {"active", "inactive", "relieved"}:
+ stmt = stmt.where(Employee.status == normalized_status)
+ elif normalized_status == "all":
+ pass
+ elif not include_inactive:
stmt = stmt.where(Employee.is_active.is_(True))
link_status = (link_status or "all").lower()
@@ -3617,7 +3624,11 @@ def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engage
def _employee_task_category(task: ClientServiceTaskInstance) -> str:
- value = (getattr(task, "task_category", None) or "").strip()
+ value = (
+ getattr(task, "_resolved_task_category", None)
+ or getattr(task, "task_category", None)
+ or ""
+ ).strip()
return value or "General Workflow"
@@ -4021,10 +4032,37 @@ def get_employee_engagement_work_board(
if int(row.id) in assigned_ids
or (getattr(row, "default_role_name", None) or "").strip().lower() in reviewer_roles
]
- use_task_categories = any(
- bool((getattr(row, "task_category", None) or "").strip())
+ # Resolve task categories from the current Task Category master/template when
+ # older generated task instances do not carry the category snapshot. This
+ # keeps historical engagements category-based without rewriting task history.
+ template_ids = {
+ int(row.firm_task_template_id)
for row in tasks
- )
+ if getattr(row, "firm_task_template_id", None) is not None
+ }
+ resolved_categories: dict[int, tuple[str, int]] = {}
+ if template_ids:
+ category_rows = db.execute(
+ select(
+ FirmServiceTaskTemplate.id,
+ FirmServiceTaskTemplate.task_category,
+ ServiceTaskCategory.name,
+ ServiceTaskCategory.sort_order,
+ )
+ .outerjoin(ServiceTaskCategory, ServiceTaskCategory.id == FirmServiceTaskTemplate.task_category_id)
+ .where(FirmServiceTaskTemplate.id.in_(template_ids))
+ ).all()
+ for template_id, legacy_name, master_name, sort_order in category_rows:
+ resolved_name = (master_name or legacy_name or "").strip()
+ if resolved_name:
+ resolved_categories[int(template_id)] = (resolved_name, int(sort_order or 100))
+ for row in tasks:
+ template_id = getattr(row, "firm_task_template_id", None)
+ resolved = resolved_categories.get(int(template_id)) if template_id is not None else None
+ row._resolved_task_category = resolved[0] if resolved else ((getattr(row, "task_category", None) or "").strip() or "General Workflow")
+ row._resolved_task_category_sort = resolved[1] if resolved else 100
+
+ use_task_categories = any(_employee_task_category(row) != "General Workflow" for row in tasks)
summary = {
"total": len(tasks),
@@ -4056,6 +4094,7 @@ def get_employee_engagement_work_board(
if category is None:
category = {
"name": category_name,
+ "sort_order": int(getattr(task, "_resolved_task_category_sort", 100) or 100),
"tasks": [],
"total": 0,
"completed": 0,
@@ -4075,6 +4114,8 @@ def get_employee_engagement_work_board(
elif status_code == "in_progress":
category["in_progress"] += 1
+ categories.sort(key=lambda item: (int(item.get("sort_order", 100)), item["name"].lower()))
+
for category in categories:
weighted_category = _weighted_progress(category["tasks"])
category.update(weighted_category)
diff --git a/app/modules/employees/templates/employees/list.html b/app/modules/employees/templates/employees/list.html
index 62e74d7..2f45ac4 100644
--- a/app/modules/employees/templates/employees/list.html
+++ b/app/modules/employees/templates/employees/list.html
@@ -22,9 +22,12 @@
-
+
@@ -41,7 +44,7 @@
{{ link_summary.unlinked }} employee(s) are not linked to login users.
Employee self-service pages such as My Workspace, attendance, leave, documents and payslips work fully only after the employee master is linked to an IAM user.
-
Show unlinked employees
+
Show unlinked employees
{% endif %}
diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html
index b2196c4..95f0eee 100644
--- a/app/modules/employees/templates/employees/work_engagement_board.html
+++ b/app/modules/employees/templates/employees/work_engagement_board.html
@@ -114,9 +114,12 @@
{% for category in board.categories %}
{% if board.use_task_categories %}
-
- {{ category.name }} · {{ category.completed }}/{{ category.total }}
-
+
+
+ ▶{{ category.name }}
+ {{ category.completed }}/{{ category.total }} completed
+
+
{% endif %}
{% for task in category.tasks %}
@@ -365,6 +368,7 @@
{% endfor %}
+ {% if board.use_task_categories %}{% endif %}
{% endfor %}
diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py
index caf83c6..c0f62b6 100644
--- a/app/modules/employees/ui.py
+++ b/app/modules/employees/ui.py
@@ -513,7 +513,7 @@ def _safe_employee_return_url(value: str | None, fallback: str) -> str:
@router.get("")
-def employees_list(request: Request, q: str = "", include_inactive: str | None = None, link_status: str = "all"):
+def employees_list(request: Request, q: str = "", include_inactive: str | None = None, link_status: str = "all", status: str | None = None):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
@@ -527,12 +527,19 @@ def employees_list(request: Request, q: str = "", include_inactive: str | None =
branch_id = request.session.get("active_branch_id")
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
normalized_link_status = link_status if link_status in ("all", "linked", "unlinked") else "all"
+ requested_status = (status or "").strip().lower()
+ if requested_status not in {"all", "active", "inactive", "relieved"}:
+ requested_status = "all" if (scope.is_firm_admin or scope.is_partner or scope.is_system_admin) else "active"
+ # Keep the legacy include_inactive query string working for bookmarked URLs.
+ if include_inactive and status is None:
+ requested_status = "all"
rows = list_employees(
db,
scope,
q=q,
- include_inactive=bool(include_inactive),
+ include_inactive=(requested_status == "all"),
link_status=normalized_link_status,
+ status_filter=requested_status,
)
link_summary = get_employee_user_link_summary(db, scope)
return _render(
@@ -543,7 +550,8 @@ def employees_list(request: Request, q: str = "", include_inactive: str | None =
title="Employees",
rows=rows,
q=q,
- include_inactive=bool(include_inactive),
+ include_inactive=(requested_status == "all"),
+ selected_status=requested_status,
link_status=normalized_link_status,
link_summary=link_summary,
scope=scope,
diff --git a/app/modules/firm_admin_dashboard/service.py b/app/modules/firm_admin_dashboard/service.py
index 5d07d87..1de7434 100644
--- a/app/modules/firm_admin_dashboard/service.py
+++ b/app/modules/firm_admin_dashboard/service.py
@@ -173,6 +173,8 @@ def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
"employee_id": employee.id if employee else None,
"employee_linked": bool(employee),
"employee_code": employee.employee_code if employee else None,
+ "employee_status": employee.status if employee else None,
+ "employee_is_active": employee.is_active if employee else None,
"is_active": user.is_active,
"allow_login": user.allow_login,
"is_locked": user.is_locked,
diff --git a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html
index 66872e6..dc155c0 100644
--- a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html
+++ b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html
@@ -35,6 +35,7 @@
{% if user.employee_linked %}
Linked
{{ user.employee_code or ('#' ~ user.employee_id) }}
+ {% if user.employee_status %}{{ user.employee_status|replace('_',' ')|title }}{% endif %}
{% else %}
Not linked
{% endif %}
diff --git a/app/modules/partner_dashboard/service.py b/app/modules/partner_dashboard/service.py
index afaac6c..bd18feb 100644
--- a/app/modules/partner_dashboard/service.py
+++ b/app/modules/partner_dashboard/service.py
@@ -24,6 +24,8 @@ from app.modules.services.models import (
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
+ FirmServiceTaskTemplate,
+ ServiceTaskCategory,
)
from app.modules.employees.service import _engagement_sla, _engagement_team, _weighted_progress
@@ -266,7 +268,6 @@ def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks
.join(Role, Role.id == UserRole.role_id)
.where(
User.tenant_id == tenant_id,
- User.is_active.is_(True),
Role.is_active.is_(True),
Role.name.in_(("Staff", "Branch Manager")),
)
@@ -300,13 +301,14 @@ def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks
"name": user.full_name or user.email,
"email": user.email,
"designation": user.designation,
+ "is_active": bool(user.is_active),
"active": stats["active"],
"overdue": stats["overdue"],
"review": stats["review"],
"client_pending": stats["client_pending"],
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
})
- rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"]), reverse=True)
+ rows.sort(key=lambda r: (not r["is_active"], -(r["active"] + r["review"]), -r["overdue"], r["name"].lower()))
return rows[:25]
@@ -442,7 +444,7 @@ def _display_user(user) -> str:
def _partner_task_category(task: ClientServiceTaskInstance) -> str:
- return (getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
+ return (getattr(task, "_resolved_task_category", None) or getattr(task, "task_category", None) or "General Workflow").strip() or "General Workflow"
def _partner_review_level_for_task(task: ClientServiceTaskInstance, subscription: ClientServiceSubscription, current_user) -> str | None:
@@ -650,12 +652,34 @@ def get_partner_review_workspace(db: Session, request, current_user, *, subscrip
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
)
tasks = list(db.execute(task_stmt).scalars().all())
+ template_ids = {int(t.firm_task_template_id) for t in tasks if getattr(t, "firm_task_template_id", None) is not None}
+ resolved_categories: dict[int, tuple[str, int]] = {}
+ if template_ids:
+ for template_id, legacy_name, master_name, sort_order in db.execute(
+ select(
+ FirmServiceTaskTemplate.id,
+ FirmServiceTaskTemplate.task_category,
+ ServiceTaskCategory.name,
+ ServiceTaskCategory.sort_order,
+ )
+ .outerjoin(ServiceTaskCategory, ServiceTaskCategory.id == FirmServiceTaskTemplate.task_category_id)
+ .where(FirmServiceTaskTemplate.id.in_(template_ids))
+ ).all():
+ name = (master_name or legacy_name or "").strip()
+ if name:
+ resolved_categories[int(template_id)] = (name, int(sort_order or 100))
+ for task in tasks:
+ template_id = getattr(task, "firm_task_template_id", None)
+ resolved = resolved_categories.get(int(template_id)) if template_id is not None else None
+ task._resolved_task_category = resolved[0] if resolved else ((getattr(task, "task_category", None) or "").strip() or "General Workflow")
+ task._resolved_task_category_sort = resolved[1] if resolved else 100
task_rows = [_partner_task_payload(task, subscription, current_user) for task in tasks]
categories_by_name: dict[str, dict[str, Any]] = {}
for row in task_rows:
+ source_task = next((t for t in tasks if int(t.id) == int(row["id"])), None)
cat = categories_by_name.setdefault(row["category"], {
- "name": row["category"], "tasks": [], "total": 0, "pending": 0,
+ "name": row["category"], "sort_order": int(getattr(source_task, "_resolved_task_category_sort", 100) or 100), "tasks": [], "total": 0, "pending": 0,
"reviewed": 0, "rework": 0, "exceptions": 0, "blockers": 0,
})
cat["tasks"].append(row)
@@ -665,7 +689,7 @@ def get_partner_review_workspace(db: Session, request, current_user, *, subscrip
if row["review_state"] == "rework": cat["rework"] += 1
if row["is_exception"]: cat["exceptions"] += 1
if row["blocks_final_release"] and row["aqmm_status"] != "completed": cat["blockers"] += 1
- categories = list(categories_by_name.values())
+ categories = sorted(categories_by_name.values(), key=lambda item: (int(item.get("sort_order", 100)), item["name"].lower()))
for cat in categories:
denominator = cat["pending"] + cat["reviewed"] + cat["rework"]
cat["progress_percent"] = int(round((cat["reviewed"] / denominator) * 100)) if denominator else 100
diff --git a/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html b/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html
index b45adf9..07aaca8 100644
--- a/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html
+++ b/app/modules/partner_dashboard/templates/partner_dashboard/partials/staff.html
@@ -1,6 +1,6 @@
Branch Staff Workload
Workload is calculated from current branch task assignments.
| Staff / Manager | Active | Overdue | Client Pending | Review | Load |
- {% for s in staff_rows %}{{ s.name }} {{ s.designation or s.email }} | {{ s.active }} | {{ s.overdue }} | {{ s.client_pending }} | {{ s.review }} | {{ s.load_status }} |
{% else %}| No active Staff or Branch Manager users found for this branch. |
{% endfor %}
+ {% for s in staff_rows %}{{ s.name }} {% if not s.is_active %} Inactive{% endif %} {{ s.designation or s.email }} | {{ s.active }} | {{ s.overdue }} | {{ s.client_pending }} | {{ s.review }} | {{ s.load_status }} |
{% else %}| No Staff or Branch Manager users found for this branch. |
{% endfor %}
diff --git a/app/modules/registrations/templates/registrations/client.html b/app/modules/registrations/templates/registrations/client.html
index aff3cd7..80d02e4 100644
--- a/app/modules/registrations/templates/registrations/client.html
+++ b/app/modules/registrations/templates/registrations/client.html
@@ -1 +1 @@
-{% extends "ui/templates/base/layout.html" %}{% block content %}Registration records
{% for r,t in registrations %}
{{t.name}} — {{r.registration_number}}
Valid until: {{r.valid_until or '—'}} · Next action: {{r.next_action_date or '—'}} · {{r.status}}
{% else %}
No records.
{% endfor %}
Related persons and DSC
{% for p in people %}
{{p.full_name}} — {{p.person_type}}{% if p.din %} · DIN {{p.din}}{% endif %}
{% endfor %}{% for d,p in dscs %}
DSC: {{p.full_name}} · expires {{d.expires_on}} · {{d.status}}
{% endfor %}
{% if can_manage %}
{% endif %}
{% endblock %}
+{% extends "ui/templates/base/layout.html" %}{% block content %}Registration records
{% for r,t in registrations %}
{{t.name}} — {{r.registration_number}}
Valid until: {{r.valid_until or '—'}} · Next action: {{r.next_action_date or '—'}} · {{r.status}}
{% set vault_entries = vault_by_registration.get(r.id, []) if vault_by_registration else [] %}{% if vault_entries %}
{% endif %}
{% else %}
No records.
{% endfor %}
Related persons and DSC
{% for p in people %}
{{p.full_name}} — {{p.person_type}}{% if p.din %} · DIN {{p.din}}{% endif %}
{% endfor %}{% for d,p in dscs %}
DSC: {{p.full_name}} · expires {{d.expires_on}} · {{d.status}}
{% endfor %}
{% if can_manage %}
{% endif %}
{% endblock %}
diff --git a/app/modules/registrations/ui.py b/app/modules/registrations/ui.py
index 7cfa957..735b934 100644
--- a/app/modules/registrations/ui.py
+++ b/app/modules/registrations/ui.py
@@ -10,6 +10,8 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
from app.modules.consultants.models import ConsultantProfile
+from app.modules.credential_vault.models import CredentialVaultEntry
+from app.modules.credential_vault.service import can_manage_vault, can_view_entry
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.registrations.models import *
@@ -432,7 +434,26 @@ def client_register(request:Request,client_id:int):
dscs=db.execute(select(ClientDigitalSignature,ClientRelatedPerson).join(ClientRelatedPerson).where(ClientDigitalSignature.client_id==client_id).order_by(ClientDigitalSignature.expires_on)).all()
rules=db.execute(select(RegistrationLifecycleRule).where(or_(RegistrationLifecycleRule.tenant_id.is_(None),RegistrationLifecycleRule.tenant_id==client.tenant_id),RegistrationLifecycleRule.is_active.is_(True))).scalars().all()
consultants=db.execute(select(ConsultantProfile).where(ConsultantProfile.tenant_id==client.tenant_id,ConsultantProfile.is_active.is_(True))).scalars().all()
- return templates.TemplateResponse("modules/registrations/templates/registrations/client.html",_ctx(request,user,db,client=client,registrations=regs,people=people,dscs=dscs,types=db.execute(select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by(RegistrationType.sort_order)).scalars().all(),rules=rules,consultants=consultants,can_manage=_can_manage(db,user)))
+ vault_rows = db.execute(
+ select(CredentialVaultEntry).where(
+ CredentialVaultEntry.tenant_id == client.tenant_id,
+ CredentialVaultEntry.client_id == client.id,
+ CredentialVaultEntry.status != "archived",
+ ).order_by(CredentialVaultEntry.title)
+ ).scalars().all()
+ vault_by_registration = {}
+ for entry in vault_rows:
+ if entry.registration_id and can_view_entry(db, user, entry, _branch(request, user)):
+ vault_by_registration.setdefault(int(entry.registration_id), []).append(entry)
+ return templates.TemplateResponse(
+ "modules/registrations/templates/registrations/client.html",
+ _ctx(
+ request,user,db,client=client,registrations=regs,people=people,dscs=dscs,
+ types=db.execute(select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by(RegistrationType.sort_order)).scalars().all(),
+ rules=rules,consultants=consultants,can_manage=_can_manage(db,user),
+ can_manage_vault=can_manage_vault(db,user),vault_by_registration=vault_by_registration,
+ ),
+ )
@router.post("/clients/{client_id}/people")
async def add_person(request:Request,client_id:int,person_type:str=Form(...),full_name:str=Form(...),designation:str=Form(""),pan:str=Form(""),din:str=Form(""),date_of_birth:str=Form(""),appointment_date:str=Form(""),mobile:str=Form(""),email:str=Form(""),authorised_signatory:bool=Form(False),notes:str=Form(""),csrf_token:str=Form(...)):