Add staff visibility workstation enrollment registration vault and category task views

This commit is contained in:
A R R R Associates
2026-09-02 14:05:20 +05:30
parent 1fe1597d6f
commit 97777e13a1
22 changed files with 538 additions and 103 deletions
+47 -6
View File
@@ -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)
@@ -22,9 +22,12 @@
<option value="linked" {% if link_status == 'linked' %}selected{% endif %}>Linked to login user</option>
<option value="unlinked" {% if link_status == 'unlinked' %}selected{% endif %}>Not linked to login user</option>
</select>
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
</label>
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>All staff statuses</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if selected_status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="relieved" {% if selected_status == 'relieved' %}selected{% endif %}>Relieved</option>
</select>
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
</div>
</form>
@@ -41,7 +44,7 @@
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div class="font-semibold">{{ link_summary.unlinked }} employee(s) are not linked to login users.</div>
<p class="mt-1">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.</p>
<a href="/employees?link_status=unlinked{% if include_inactive %}&include_inactive=1{% endif %}" class="mt-2 inline-flex rounded-lg border border-amber-300 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100">Show unlinked employees</a>
<a href="/employees?link_status=unlinked&status={{ selected_status or 'all' }}" class="mt-2 inline-flex rounded-lg border border-amber-300 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100">Show unlinked employees</a>
</div>
{% endif %}
@@ -114,9 +114,12 @@
<div class="divide-y divide-slate-100">
{% for category in board.categories %}
{% if board.use_task_categories %}
<div class="bg-slate-50 px-5 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
{{ category.name }} · {{ category.completed }}/{{ category.total }}
</div>
<details class="group" {% if loop.first %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 bg-slate-50 px-5 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-100">
<span class="flex items-center gap-2"><span class="text-slate-400 transition-transform group-open:rotate-90">▶</span>{{ category.name }}</span>
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ category.completed }}/{{ category.total }} completed</span>
</summary>
<div class="divide-y divide-slate-100">
{% endif %}
{% for task in category.tasks %}
@@ -365,6 +368,7 @@
</div>
</div>
{% endfor %}
{% if board.use_task_categories %}</div></details>{% endif %}
{% endfor %}
</div>
</section>
+11 -3
View File
@@ -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,