Add optional task categories resequencing and requirement aware documents
This commit is contained in:
@@ -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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user