Add optional task categories resequencing and requirement aware documents

This commit is contained in:
A R R R Associates
2026-08-12 14:57:20 +05:30
parent eb18a5695f
commit 74586d60ed
5 changed files with 128 additions and 4 deletions
+31 -1
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.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.clients.models import Client, ClientBusinessUnit, ClientBranch
from app.modules.documents.models import EngagementDocument from app.modules.documents.models import EngagementDocument
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement
from app.modules.registrations.models import ClientRegistration, RegistrationType from app.modules.registrations.models import ClientRegistration, RegistrationType
from app.modules.services.execution import ( from app.modules.services.execution import (
CLOSED_TASK_STATUSES, CLOSED_TASK_STATUSES,
@@ -3890,6 +3890,10 @@ def get_employee_engagement_work_board(
if int(row.id) in assigned_ids if int(row.id) in assigned_ids
or (getattr(row, "default_role_name", None) or "").strip().lower() in reviewer_roles 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())
for row in tasks
)
summary = { summary = {
"total": len(tasks), "total": len(tasks),
@@ -3984,6 +3988,27 @@ def get_employee_engagement_work_board(
engagement_documents = list_employee_engagement_documents( engagement_documents = list_employee_engagement_documents(
db, scope, engagement_id, financial_year=financial_year db, scope, engagement_id, financial_year=financial_year
) )
firm_template_ids = {
int(row.firm_task_template_id)
for row in tasks
if getattr(row, "firm_task_template_id", None) is not None
}
mandatory_requirement_template_ids: set[int] = set()
if firm_template_ids:
mandatory_requirement_template_ids = {
int(template_id)
for (template_id,) in db.execute(
select(FirmTaskDocumentRequirement.firm_task_template_id).where(
FirmTaskDocumentRequirement.tenant_id == scope.tenant_id,
FirmTaskDocumentRequirement.firm_task_template_id.in_(firm_template_ids),
FirmTaskDocumentRequirement.is_active.is_(True),
FirmTaskDocumentRequirement.is_mandatory.is_(True),
)
).all()
if template_id is not None
}
task_document_counts: dict[int, int] = {} task_document_counts: dict[int, int] = {}
for document in engagement_documents: for document in engagement_documents:
task_instance_id = getattr(document, "task_instance_id", None) task_instance_id = getattr(document, "task_instance_id", None)
@@ -4004,6 +4029,10 @@ def get_employee_engagement_work_board(
read_only=int(task.id) not in assigned_ids, read_only=int(task.id) not in assigned_ids,
) )
task.i1_documents = task_documents.get(int(task.id), []) task.i1_documents = task_documents.get(int(task.id), [])
task.i1_has_mandatory_document_requirement = bool(
getattr(task, "firm_task_template_id", None) is not None
and int(task.firm_task_template_id) in mandatory_requirement_template_ids
)
scope_display = _employee_work_scope_display_map( scope_display = _employee_work_scope_display_map(
db, db,
@@ -4027,6 +4056,7 @@ def get_employee_engagement_work_board(
"sla": _engagement_sla(subscription, tasks), "sla": _engagement_sla(subscription, tasks),
"escalation_levels": WORKFLOW_ESCALATION_LEVELS, "escalation_levels": WORKFLOW_ESCALATION_LEVELS,
"categories": categories, "categories": categories,
"use_task_categories": use_task_categories,
"active_task": selected_task, "active_task": selected_task,
"active_category": selected_category, "active_category": selected_category,
"next_task": next_task, "next_task": next_task,
@@ -108,9 +108,11 @@
<div class="divide-y divide-slate-100"> <div class="divide-y divide-slate-100">
{% for category in board.categories %} {% 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"> <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 }} {{ category.name }} · {{ category.completed }}/{{ category.total }}
</div> </div>
{% endif %}
{% for task in category.tasks %} {% for task in category.tasks %}
{% set status = task.i1_status or (task.status or 'pending') %} {% set status = task.i1_status or (task.status or 'pending') %}
@@ -207,12 +209,14 @@
<span class="rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-500">🔒 Locked</span> <span class="rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-500">🔒 Locked</span>
{% endif %} {% endif %}
{% if task.i1_has_mandatory_document_requirement %}
<button type="button" <button type="button"
data-i1-panel-trigger="documents" data-i1-panel-trigger="documents"
data-i1-task-id="{{ task.id }}" data-i1-task-id="{{ task.id }}"
class="rounded-xl border border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100 px-3 py-2 text-sm font-semibold"> class="rounded-xl border border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100 px-3 py-2 text-sm font-semibold">
Documents{% if task.i1_documents %} · {{ task.i1_documents|length }}{% endif %} Documents{% if task.i1_documents %} · {{ task.i1_documents|length }}{% endif %}
</button> </button>
{% endif %}
<button type="button" <button type="button"
data-i1-panel-trigger="messages" data-i1-panel-trigger="messages"
@@ -231,6 +235,7 @@
</div> </div>
<div class="mt-3 space-y-3" data-i1-panels-for-task="{{ task.id }}"> <div class="mt-3 space-y-3" data-i1-panels-for-task="{{ task.id }}">
{% if task.i1_has_mandatory_document_requirement %}
<div data-i1-panel="documents" class="hidden w-full rounded-2xl border border-violet-200 bg-violet-50/40 p-4"> <div data-i1-panel="documents" class="hidden w-full rounded-2xl border border-violet-200 bg-violet-50/40 p-4">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -268,6 +273,7 @@
<button type="submit" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-700">Upload</button> <button type="submit" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-700">Upload</button>
</form> </form>
</div> </div>
{% endif %}
<div data-i1-panel="messages" class="hidden w-full rounded-2xl border border-sky-200 bg-sky-50/40 p-4"> <div data-i1-panel="messages" class="hidden w-full rounded-2xl border border-sky-200 bg-sky-50/40 p-4">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
@@ -80,8 +80,8 @@
<p class="mt-1 text-xs text-emerald-800">Configure this task as an auditable checklist item. Normal tasks can keep Response Type as None.</p> <p class="mt-1 text-xs text-emerald-800">Configure this task as an auditable checklist item. Normal tasks can keep Response Type as None.</p>
<div class="mt-3 grid gap-4 md:grid-cols-2"> <div class="mt-3 grid gap-4 md:grid-cols-2">
<div> <div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category <span class="font-normal normal-case text-emerald-600">(optional)</span></label>
<input type="text" name="task_category" value="{{ task.task_category or '' }}" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm" placeholder="e.g. Accounting Verification"> <input type="text" name="task_category" value="{{ task.task_category or '' }}" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm" placeholder="Leave blank for the normal flat checklist">
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Response Type</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Response Type</label>
@@ -18,6 +18,8 @@
{% if request.query_params.get('requirement_added') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document requirement added successfully.</div>{% endif %} {% if request.query_params.get('requirement_added') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document requirement added successfully.</div>{% endif %}
{% if request.query_params.get('template_uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Template file uploaded successfully.</div>{% endif %} {% if request.query_params.get('template_uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Template file uploaded successfully.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check the selected task, file and permissions.</div>{% endif %} {% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check the selected task, file and permissions.</div>{% endif %}
{% if request.query_params.get('moved') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Task sequence updated. Use Sync Firm Tasks to Open Engagements if you want safe pending/open task snapshots refreshed.</div>{% endif %}
{% if request.query_params.get('move_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Task could not be moved.</div>{% endif %}
<section class="grid gap-4 md:grid-cols-3"> <section class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl bg-white p-5 shadow-soft"> <div class="rounded-2xl bg-white p-5 shadow-soft">
@@ -77,6 +79,10 @@
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</label>
<input name="sequence_no" type="number" min="1" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Auto"> <input name="sequence_no" type="number" min="1" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Auto">
</div> </div>
<div class="lg:col-span-3">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Task Category <span class="font-normal normal-case text-slate-400">(optional)</span></label>
<input name="task_category" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="e.g. GST, Accounts, Income Tax">
</div>
<div class="lg:col-span-3"> <div class="lg:col-span-3">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Options</label> <label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Options</label>
<div class="flex flex-wrap gap-3 rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-700"> <div class="flex flex-wrap gap-3 rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-700">
@@ -156,14 +162,38 @@
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for task in task_templates %} {% for task in task_templates %}
<tr> <tr>
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ task.sequence_no }}</td> <td class="px-4 py-3 text-sm font-medium text-slate-900 align-top">
<div class="flex items-center gap-2">
<span class="min-w-6">{{ task.sequence_no }}</span>
{% if can_manage_tasks %}
<div class="flex flex-col gap-1">
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/move">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="up">
<button type="submit" title="Move task up" aria-label="Move task up"
class="h-6 w-7 rounded border border-slate-300 bg-white text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-30"
{% if loop.first %}disabled{% endif %}>↑</button>
</form>
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/move">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="down">
<button type="submit" title="Move task down" aria-label="Move task down"
class="h-6 w-7 rounded border border-slate-300 bg-white text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-30"
{% if loop.last %}disabled{% endif %}>↓</button>
</form>
</div>
{% endif %}
</div>
</td>
<td class="px-4 py-3 text-sm text-slate-700"> <td class="px-4 py-3 text-sm text-slate-700">
<div class="font-medium text-slate-900">{{ task.task_name }}</div> <div class="font-medium text-slate-900">{{ task.task_name }}</div>
{% if task.task_category %}<div class="mt-1 text-[11px] font-semibold uppercase tracking-wide text-brand-600">Category: {{ task.task_category }}</div>{% endif %}
{% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %} {% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %}
</td> </td>
<td class="px-4 py-3 text-sm text-slate-700"><div>{{ task.default_role_name or '-' }}</div><div class="mt-1 text-xs text-slate-500">Eligible: {{ task.eligible_role_names or task.default_role_name or '-' }}</div></td> <td class="px-4 py-3 text-sm text-slate-700"><div>{{ task.default_role_name or '-' }}</div><div class="mt-1 text-xs text-slate-500">Eligible: {{ task.eligible_role_names or task.default_role_name or '-' }}</div></td>
<td class="px-4 py-3 text-xs text-slate-600"> <td class="px-4 py-3 text-xs text-slate-600">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{% if task.task_category %}<span class="rounded-full bg-blue-50 px-2 py-1 text-blue-700">{{ task.task_category }}</span>{% endif %}
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %} {% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
{% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review: {{ (task.normal_review_role or "manager").replace("_"," ").title() }}</span>{% endif %} {% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review: {{ (task.normal_review_role or "manager").replace("_"," ").title() }}</span>{% endif %}
{% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM</span>{% endif %} {% if task.is_aqmm_task %}<span class="rounded-full bg-indigo-100 px-2 py-1 text-indigo-800">AQMM</span>{% endif %}
+58
View File
@@ -1664,6 +1664,64 @@ def sync_firm_tasks_to_open_engagements(
db.close() db.close()
@router.post('/templates/{catalogue_id}/tasks/{task_id}/move')
def firm_task_template_move(
request: Request,
catalogue_id: int,
task_id: int,
direction: str = Form(...),
csrf_token: str = Form(...),
):
"""Move one firm task one position up/down without changing task identity."""
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url='/login', status_code=303)
if not _can_manage_firm_tasks(db, user):
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
catalogue = get_catalogue(db, catalogue_id)
if not catalogue:
return RedirectResponse(url='/services/templates', status_code=303)
selection = get_firm_selection(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
if not selection or not selection.is_enabled:
return RedirectResponse(url='/services', status_code=303)
tasks = get_firm_task_templates(db, tenant_id=tenant_id, catalogue_id=catalogue_id)
index = next((i for i, row in enumerate(tasks) if int(row.id) == int(task_id)), None)
direction_value = (direction or '').strip().lower()
if index is None or direction_value not in {'up', 'down'}:
return RedirectResponse(url=f'/services/templates/{catalogue_id}?move_error=1', status_code=303)
other_index = index - 1 if direction_value == 'up' else index + 1
if other_index < 0 or other_index >= len(tasks):
return RedirectResponse(url=f'/services/templates/{catalogue_id}', status_code=303)
current = tasks[index]
other = tasks[other_index]
current_seq = int(current.sequence_no)
other_seq = int(other.sequence_no)
# Swap safely across the existing unique tenant/service/sequence constraint.
min_seq = min(int(row.sequence_no) for row in tasks)
current.sequence_no = min_seq - 1000000 - int(current.id)
current.updated_by_user_id = user.id
db.flush()
other.sequence_no = current_seq
other.updated_by_user_id = user.id
db.flush()
current.sequence_no = other_seq
db.commit()
return RedirectResponse(url=f'/services/templates/{catalogue_id}?moved=1', status_code=303)
finally:
db.close()
@router.get('/templates/{catalogue_id}/tasks/{task_id}/edit') @router.get('/templates/{catalogue_id}/tasks/{task_id}/edit')
def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int): def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int):
db = CommonSessionLocal() db = CommonSessionLocal()