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.clients.models import Client, ClientBusinessUnit, ClientBranch
|
||||
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.services.execution import (
|
||||
CLOSED_TASK_STATUSES,
|
||||
@@ -3890,6 +3890,10 @@ 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())
|
||||
for row in tasks
|
||||
)
|
||||
|
||||
summary = {
|
||||
"total": len(tasks),
|
||||
@@ -3984,6 +3988,27 @@ def get_employee_engagement_work_board(
|
||||
engagement_documents = list_employee_engagement_documents(
|
||||
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] = {}
|
||||
for document in engagement_documents:
|
||||
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,
|
||||
)
|
||||
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(
|
||||
db,
|
||||
@@ -4027,6 +4056,7 @@ def get_employee_engagement_work_board(
|
||||
"sla": _engagement_sla(subscription, tasks),
|
||||
"escalation_levels": WORKFLOW_ESCALATION_LEVELS,
|
||||
"categories": categories,
|
||||
"use_task_categories": use_task_categories,
|
||||
"active_task": selected_task,
|
||||
"active_category": selected_category,
|
||||
"next_task": next_task,
|
||||
|
||||
@@ -108,9 +108,11 @@
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% for task in category.tasks %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{% if task.i1_has_mandatory_document_requirement %}
|
||||
<button type="button"
|
||||
data-i1-panel-trigger="documents"
|
||||
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">
|
||||
Documents{% if task.i1_documents %} · {{ task.i1_documents|length }}{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button type="button"
|
||||
data-i1-panel-trigger="messages"
|
||||
@@ -231,6 +235,7 @@
|
||||
</div>
|
||||
|
||||
<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 class="flex flex-wrap items-start justify-between gap-3">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<div class="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-800">Task Category</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">
|
||||
<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="Leave blank for the normal flat checklist">
|
||||
</div>
|
||||
<div>
|
||||
<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('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('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">
|
||||
<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>
|
||||
<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 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">
|
||||
<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">
|
||||
@@ -156,14 +162,38 @@
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for task in task_templates %}
|
||||
<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">
|
||||
<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 %}
|
||||
</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">
|
||||
<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.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 %}
|
||||
|
||||
@@ -1664,6 +1664,64 @@ def sync_firm_tasks_to_open_engagements(
|
||||
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')
|
||||
def firm_task_template_edit_page(request: Request, catalogue_id: int, task_id: int):
|
||||
db = CommonSessionLocal()
|
||||
|
||||
Reference in New Issue
Block a user