Implement I1 simplified engagement checklist
This commit is contained in:
@@ -31,6 +31,7 @@ from app.modules.services.execution import (
|
||||
TASK_STATUSES,
|
||||
apply_task_checklist_response,
|
||||
recalculate_task_aqmm_status,
|
||||
submit_task_for_review,
|
||||
)
|
||||
|
||||
EMPLOYEE_STATUS = ["active", "inactive", "relieved"]
|
||||
@@ -2611,6 +2612,7 @@ def list_employee_work_dashboard(
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.template),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
.where(
|
||||
@@ -3450,6 +3452,112 @@ def _employee_task_category(task: ClientServiceTaskInstance) -> str:
|
||||
return value or "General Workflow"
|
||||
|
||||
|
||||
|
||||
def _i1_required_review_levels(task: ClientServiceTaskInstance) -> list[str]:
|
||||
"""Return configured review levels for I1 using existing task-template controls."""
|
||||
levels: list[str] = []
|
||||
if getattr(task, "aqmm_manager_review_required", False):
|
||||
levels.append("manager")
|
||||
if getattr(task, "aqmm_partner_review_required", False):
|
||||
levels.append("partner")
|
||||
if getattr(task, "aqmm_review_partner_required", False):
|
||||
levels.append("review_partner")
|
||||
|
||||
# Preserve the pre-existing generic requires_review flag. Where no explicit
|
||||
# reviewer flag was configured, route to Manager when available, otherwise
|
||||
# Engagement Partner. No new review table/state is introduced.
|
||||
template = getattr(task, "template", None)
|
||||
if not levels and getattr(template, "requires_review", False):
|
||||
subscription = getattr(task, "subscription", None)
|
||||
if getattr(subscription, "assigned_manager_user_id", None):
|
||||
levels.append("manager")
|
||||
elif getattr(subscription, "assigned_partner_user_id", None):
|
||||
levels.append("partner")
|
||||
return levels
|
||||
|
||||
|
||||
def _i1_review_level_pending(task: ClientServiceTaskInstance, level: str) -> bool:
|
||||
if level == "manager":
|
||||
return (getattr(task, "manager_review_status", None) or "not_required") != "reviewed"
|
||||
if level == "partner":
|
||||
return (getattr(task, "partner_review_status", None) or "not_required") != "reviewed"
|
||||
if level == "review_partner":
|
||||
return (getattr(task, "review_partner_review_status", None) or "not_required") != "reviewed"
|
||||
return False
|
||||
|
||||
|
||||
def _i1_review_label(task: ClientServiceTaskInstance) -> str | None:
|
||||
levels = _i1_required_review_levels(task)
|
||||
if not levels:
|
||||
return None
|
||||
if getattr(task, "rework_status", "none") == "open":
|
||||
return "Returned for correction"
|
||||
pending = [level for level in levels if _i1_review_level_pending(task, level)]
|
||||
if not pending:
|
||||
return "Approved"
|
||||
labels = {
|
||||
"manager": "Manager",
|
||||
"partner": "Partner",
|
||||
"review_partner": "Review Partner",
|
||||
}
|
||||
return "Awaiting " + " + ".join(labels[level] for level in pending) + " approval"
|
||||
|
||||
|
||||
def _i1_approval_blocker(
|
||||
tasks: list[ClientServiceTaskInstance],
|
||||
target_task: ClientServiceTaskInstance,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Lock downstream staff work until required approval on an earlier task is complete."""
|
||||
ordered = sorted(tasks, key=lambda row: (int(getattr(row, "sequence_no", 0) or 0), int(row.id)))
|
||||
for row in ordered:
|
||||
if row.id == target_task.id:
|
||||
break
|
||||
levels = _i1_required_review_levels(row)
|
||||
if not levels:
|
||||
continue
|
||||
pending = [level for level in levels if _i1_review_level_pending(row, level)]
|
||||
if not pending:
|
||||
continue
|
||||
labels = {
|
||||
"manager": "Manager",
|
||||
"partner": "Partner",
|
||||
"review_partner": "Review Partner",
|
||||
}
|
||||
return {
|
||||
"task_id": int(row.id),
|
||||
"task_name": row.task_name,
|
||||
"label": _i1_review_label(row) or "Approval pending",
|
||||
"levels": [labels[level] for level in pending],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _i1_prepare_task_display(
|
||||
task: ClientServiceTaskInstance,
|
||||
*,
|
||||
tasks: list[ClientServiceTaskInstance],
|
||||
task_document_counts: dict[int, int],
|
||||
) -> None:
|
||||
status = (getattr(task, "status", None) or "pending").strip().lower()
|
||||
task.i1_status = status
|
||||
task.i1_review_levels = _i1_required_review_levels(task)
|
||||
task.i1_review_label = _i1_review_label(task)
|
||||
task.i1_approval_blocker = _i1_approval_blocker(tasks, task)
|
||||
task.i1_has_evidence = task_document_counts.get(int(task.id), 0) > 0
|
||||
task.i1_needs_response = bool(
|
||||
getattr(task, "response_required", False)
|
||||
or (getattr(task, "response_type", "NONE") or "NONE").strip().upper() != "NONE"
|
||||
)
|
||||
task.i1_needs_evidence = bool(getattr(task, "evidence_required", False))
|
||||
task.i1_quick_complete = bool(
|
||||
not task.i1_approval_blocker
|
||||
and not task.i1_needs_response
|
||||
and (not task.i1_needs_evidence or task.i1_has_evidence)
|
||||
and not getattr(task, "is_locked", False)
|
||||
and not getattr(getattr(task, "subscription", None), "is_locked", False)
|
||||
)
|
||||
|
||||
|
||||
def _employee_workflow_next_task(tasks: list[ClientServiceTaskInstance], current_task_id: int) -> ClientServiceTaskInstance | None:
|
||||
current_index = next((index for index, row in enumerate(tasks) if row.id == current_task_id), -1)
|
||||
ordered = tasks[current_index + 1 :] + tasks[: max(current_index, 0)]
|
||||
@@ -3729,6 +3837,28 @@ def get_employee_engagement_work_board(
|
||||
manual_blocker = _workflow_manual_blocker(subscription)
|
||||
automatic_blocker = _workflow_automatic_blocker(subscription, tasks)
|
||||
|
||||
engagement_documents = list_employee_engagement_documents(
|
||||
db, scope, engagement_id, financial_year=financial_year
|
||||
)
|
||||
task_document_counts: dict[int, int] = {}
|
||||
for document in engagement_documents:
|
||||
task_instance_id = getattr(document, "task_instance_id", None)
|
||||
if task_instance_id:
|
||||
task_document_counts[int(task_instance_id)] = task_document_counts.get(int(task_instance_id), 0) + 1
|
||||
|
||||
for task in tasks:
|
||||
_i1_prepare_task_display(
|
||||
task,
|
||||
tasks=tasks,
|
||||
task_document_counts=task_document_counts,
|
||||
)
|
||||
|
||||
scope_display = _employee_work_scope_display_map(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
subscriptions=[subscription] if subscription is not None else [],
|
||||
).get(int(subscription.id), {}) if subscription is not None else {}
|
||||
|
||||
return {
|
||||
"engagement_id": engagement_id,
|
||||
"subscription": subscription,
|
||||
@@ -3748,9 +3878,9 @@ def get_employee_engagement_work_board(
|
||||
"active_task": selected_task,
|
||||
"active_category": selected_category,
|
||||
"next_task": next_task,
|
||||
"documents": list_employee_engagement_documents(
|
||||
db, scope, engagement_id, financial_year=financial_year
|
||||
),
|
||||
"documents": engagement_documents,
|
||||
"scope_display": scope_display,
|
||||
"i1_enabled": True,
|
||||
"today": today,
|
||||
}
|
||||
|
||||
@@ -3786,9 +3916,26 @@ def save_employee_workflow_task(
|
||||
raise ValueError("Locked task cannot be changed.")
|
||||
|
||||
normalised_status = (status or "pending").strip().lower()
|
||||
if normalised_status not in {"pending", "in_progress", "blocked", "completed"}:
|
||||
if normalised_status not in {"pending", "in_progress", "blocked", "completed", "not_applicable"}:
|
||||
raise ValueError("Invalid task status.")
|
||||
|
||||
engagement_tasks_for_gate = db.execute(
|
||||
_employee_work_task_query(
|
||||
db, scope, assigned_only=True, financial_year=financial_year
|
||||
)
|
||||
.where(ClientServiceTaskInstance.subscription_id == task.subscription_id)
|
||||
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).scalars().all()
|
||||
approval_blocker = _i1_approval_blocker(engagement_tasks_for_gate, task)
|
||||
if approval_blocker and normalised_status in {"in_progress", "completed", "not_applicable"}:
|
||||
raise ValueError(approval_blocker["label"])
|
||||
|
||||
clean_remarks = (remarks or "").strip()
|
||||
if normalised_status == "blocked" and not clean_remarks:
|
||||
raise ValueError("Select or enter a blocker reason.")
|
||||
if normalised_status == "not_applicable" and not clean_remarks:
|
||||
raise ValueError("A short reason is required for Not Applicable.")
|
||||
|
||||
apply_task_checklist_response(
|
||||
db,
|
||||
task,
|
||||
@@ -3801,16 +3948,27 @@ def save_employee_workflow_task(
|
||||
)
|
||||
|
||||
task.status = normalised_status
|
||||
if normalised_status == "completed":
|
||||
if normalised_status in CLOSED_TASK_STATUSES:
|
||||
task.completed_at_utc = datetime.now(timezone.utc)
|
||||
else:
|
||||
task.completed_at_utc = None
|
||||
if normalised_status == "in_progress" and not getattr(task, "started_at_utc", None):
|
||||
task.started_at_utc = datetime.now(timezone.utc)
|
||||
if remarks.strip():
|
||||
task.remarks = remarks.strip()
|
||||
if clean_remarks:
|
||||
task.remarks = clean_remarks
|
||||
task.updated_by_user_id = actor_user_id
|
||||
recalculate_task_aqmm_status(db, task)
|
||||
|
||||
# I1 approval gate: completing a task configured for Manager/Partner review
|
||||
# automatically sends the existing task to the existing review workflow.
|
||||
if normalised_status == "completed" and _i1_required_review_levels(task):
|
||||
submit_task_for_review(
|
||||
db,
|
||||
task=task,
|
||||
note=clean_remarks or "Completed by staff and submitted for approval.",
|
||||
user_id=actor_user_id,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
@@ -3826,6 +3984,64 @@ def save_employee_workflow_task(
|
||||
|
||||
|
||||
|
||||
def quick_update_employee_workflow_task(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
task_id: int,
|
||||
*,
|
||||
action: str,
|
||||
reason: str,
|
||||
actor_user_id: int,
|
||||
financial_year: str | None = None,
|
||||
) -> tuple[ClientServiceTaskInstance, ClientServiceTaskInstance | None]:
|
||||
"""I1 one-click task action using the existing task/checklist validation path."""
|
||||
action_code = (action or "").strip().lower()
|
||||
status_map = {
|
||||
"done": "completed",
|
||||
"blocked": "blocked",
|
||||
"na": "not_applicable",
|
||||
}
|
||||
if action_code not in status_map:
|
||||
raise ValueError("Invalid checklist action.")
|
||||
|
||||
stmt = _employee_work_task_query(
|
||||
db, scope, assigned_only=True, financial_year=financial_year
|
||||
).where(ClientServiceTaskInstance.id == task_id)
|
||||
task = db.execute(stmt).scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found or not assigned to you")
|
||||
|
||||
reason_text = (reason or "").strip()
|
||||
if action_code == "blocked" and not reason_text:
|
||||
raise ValueError("A blocker reason is required.")
|
||||
if action_code == "na" and not reason_text:
|
||||
raise ValueError("A short reason is required for Not Applicable.")
|
||||
|
||||
return save_employee_workflow_task(
|
||||
db,
|
||||
scope,
|
||||
task_id,
|
||||
status=status_map[action_code],
|
||||
remarks=reason_text or (getattr(task, "remarks", None) or ""),
|
||||
checklist_response=getattr(task, "checklist_response", None) or "",
|
||||
checklist_text_response=getattr(task, "checklist_text_response", None) or "",
|
||||
checklist_number_response=(
|
||||
str(getattr(task, "checklist_number_response", ""))
|
||||
if getattr(task, "checklist_number_response", None) is not None
|
||||
else ""
|
||||
),
|
||||
checklist_date_response=(
|
||||
str(getattr(task, "checklist_date_response", ""))
|
||||
if getattr(task, "checklist_date_response", None) is not None
|
||||
else ""
|
||||
),
|
||||
checklist_remarks=getattr(task, "checklist_remarks", None) or "",
|
||||
actor_user_id=actor_user_id,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def list_employee_work_assignable_users(db: Session, scope: EmployeeScope) -> list[User]:
|
||||
"""Users that can be assigned engagement/service tasks in the active employee scope."""
|
||||
stmt = (
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ board.label }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ board.client.client_name if board.client else 'Unlinked Client' }}
|
||||
{% if board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %}
|
||||
{% set scope_display = board.scope_display or {} %}
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
<span class="font-semibold">{{ scope_display.primary or (board.client.trade_name if board.client and board.client.trade_name else (board.client.client_name if board.client else 'Unlinked Client')) }}</span>
|
||||
{% if scope_display.secondary %} · {{ scope_display.secondary }}{% elif board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %}
|
||||
</p>
|
||||
{% if scope_display.context %}<p class="mt-1 text-xs text-slate-400">{{ scope_display.context }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if board.manual_blocker %}
|
||||
@@ -20,232 +22,268 @@
|
||||
{% elif not (board.subscription and board.subscription.is_locked) %}
|
||||
<button type="button" onclick="document.getElementById('pause-workflow-panel').classList.toggle('hidden')" class="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-100">Pause / Hold</button>
|
||||
{% endif %}
|
||||
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a>
|
||||
<a href="/documents/engagements/{{ board.engagement_id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Engagement Documents</a>
|
||||
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('quick_saved') %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Checklist updated.</div>
|
||||
{% endif %}
|
||||
{% if request.query_params.get('quick_error') %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ request.query_params.get('quick_error')|replace('+', ' ') }}</div>
|
||||
{% endif %}
|
||||
{% if request.query_params.get('workflow_error') %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The task could not be saved. Complete any required response/evidence or resolve the approval gate.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.query_params.get('paused') %}<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Engagement workflow paused and the assigned Manager was notified.</div>{% endif %}
|
||||
{% if request.query_params.get('resumed') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Engagement workflow resumed.</div>{% endif %}
|
||||
{% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The workflow status could not be changed. Confirm the reason and that the engagement is not locked.</div>{% endif %}
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Weighted Progress</div><div class="mt-2 text-3xl font-semibold">{{ board.progress_percent }}%</div><div class="mt-1 text-xs text-slate-500">{{ board.weighted_progress.completed_weight }} of {{ board.weighted_progress.total_weight }} weighted points</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">SLA & Ageing</div><div class="mt-2 text-lg font-semibold {% if board.sla.status == 'breached' %}text-red-700{% elif board.sla.status in ['critical','warning'] %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ board.sla.label }}</div><div class="mt-1 text-xs text-slate-500">Age {{ board.sla.age_days }} day(s){% if board.sla.due_date %} · Due {{ board.sla.due_date }}{% endif %}</div></div>
|
||||
<div class="af-metric-card md:col-span-2"><div class="text-xs font-semibold uppercase text-slate-500">Engagement Team</div><div class="mt-3 flex flex-wrap gap-2">{% for member in board.engagement_team %}<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs"><strong>{{ member.role }}:</strong> {{ member.name }}</span>{% else %}<span class="text-sm text-slate-500">No engagement team assigned.</span>{% endfor %}</div></div>
|
||||
</section>
|
||||
|
||||
{% if request.query_params.get('escalated') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Escalation alert sent successfully.</div>{% endif %}
|
||||
{% if request.query_params.get('escalation_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Escalation could not be sent. Confirm the selected reviewer is assigned and enter details.</div>{% endif %}
|
||||
|
||||
{% if board.workflow_blocker %}
|
||||
<div class="rounded-2xl border {% if board.workflow_blocker.type == 'manual' %}border-amber-300 bg-amber-50{% else %}border-violet-300 bg-violet-50{% endif %} p-4">
|
||||
<div class="font-semibold text-slate-900">{{ board.workflow_blocker.label }}</div>
|
||||
{% if board.workflow_blocker.notes %}<div class="mt-1 text-sm text-slate-700">{{ board.workflow_blocker.notes }}</div>{% endif %}
|
||||
{% if board.workflow_blocker.follow_up_date %}<div class="mt-2 text-sm font-medium text-amber-900">Follow-up date: {{ board.workflow_blocker.follow_up_date }}</div>{% endif %}
|
||||
{% if board.workflow_blocker.type == 'automatic' %}<div class="mt-2 text-xs text-violet-700">This blocker is derived automatically from the existing AQMM/review workflow.</div>{% endif %}
|
||||
{% if board.manual_blocker %}
|
||||
<div class="rounded-2xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div class="font-semibold">Engagement on hold — {{ board.manual_blocker.label }}</div>
|
||||
{% if board.manual_blocker.notes %}<div class="mt-1">{{ board.manual_blocker.notes }}</div>{% endif %}
|
||||
{% if board.manual_blocker.follow_up_date %}<div class="mt-1 font-medium">Follow-up: {{ board.manual_blocker.follow_up_date }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="pause-workflow-panel" class="hidden rounded-2xl border border-amber-300 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Pause / Hold Engagement</h3>
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/pause" class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div id="pause-workflow-panel" class="hidden rounded-2xl border border-amber-200 bg-white p-5 shadow-soft">
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/pause" class="grid gap-3 md:grid-cols-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Reason</label><select name="reason" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select reason</option>{% for code, label in board.hold_reasons.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Follow-up Date</label><input type="date" name="follow_up_date" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div class="md:col-span-3"><label class="mb-2 block text-sm font-medium text-slate-700">Remarks / Dependency Details</label><textarea name="notes" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Documents pending, query details, reviewer dependency or other follow-up information"></textarea></div>
|
||||
<div class="md:col-span-3 flex justify-end"><button type="submit" class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700">Pause and Notify Manager</button></div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Hold Reason</label>
|
||||
<select name="reason" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
{% for code, label in board.hold_reasons.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Follow-up Date</label>
|
||||
<input type="date" name="follow_up_date" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-3">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Note</label>
|
||||
<input type="text" name="notes" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional hold note">
|
||||
</div>
|
||||
<div class="md:col-span-3"><button class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white">Put Engagement on Hold</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('saved') %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Task response saved successfully.</div>
|
||||
{% endif %}
|
||||
{% if request.query_params.get('workflow_error') %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The task could not be saved. Complete the required response, remarks and evidence, and confirm that the task is not locked.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Overall Progress</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ board.progress_percent }}%</div>
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ board.progress_percent }}%"></div></div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Progress</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ board.progress_percent }}%</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ board.summary.completed }} / {{ board.summary.total }} closed</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Total</div><div class="mt-1 text-2xl font-semibold">{{ board.summary.total }}</div></div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-blue-600">In Progress</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ board.summary.in_progress }}</div></div>
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-rose-600">Blocked</div><div class="mt-1 text-2xl font-semibold text-rose-700">{{ board.summary.blocked }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ board.summary.completed }}</div></div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Pending</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ board.summary.pending }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50 p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-blue-600">In Progress</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-blue-700">{{ board.summary.in_progress }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600">Blocked</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-amber-700">{{ board.summary.blocked }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-600">Completed / N.A.</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-emerald-700">{{ board.summary.completed }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="mb-4">
|
||||
<h3 class="font-semibold text-slate-900">Workflow Groups</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Tasks assigned to you, grouped by task category.</p>
|
||||
<section class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Engagement Checklist</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Routine work is one click. Open Details only when a response, evidence, remark or communication is needed.</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for category in board.categories %}
|
||||
<div class="rounded-xl border {% if category.name == board.active_category %}border-brand-300 bg-brand-50{% else %}border-slate-200 bg-white{% endif %} p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-slate-900">{{ category.name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ category.completed }} of {{ category.total }} completed</div>
|
||||
</div>
|
||||
<span class="rounded-full px-2 py-1 text-[11px] font-semibold {% if category.status == 'completed' %}bg-emerald-100 text-emerald-700{% elif category.status == 'blocked' %}bg-rose-100 text-rose-700{% elif category.status == 'in_progress' %}bg-blue-100 text-blue-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ category.status.replace('_', ' ').title() }}</span>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"><div class="h-full rounded-full bg-brand-600" style="width: {{ category.progress_percent }}%"></div></div>
|
||||
<div class="mt-3 space-y-1.5">
|
||||
{% for task in category.tasks %}
|
||||
<a href="/employee/work/engagements/{{ board.engagement_id }}?task_id={{ task.id }}" class="flex items-center justify-between gap-2 rounded-lg px-2 py-2 text-xs {% if board.active_task and task.id == board.active_task.id %}bg-white font-semibold text-brand-700 shadow-sm{% else %}text-slate-600 hover:bg-slate-50{% endif %}">
|
||||
<span class="min-w-0 truncate">#{{ task.sequence_no }} {{ task.task_name }}</span>
|
||||
<span class="shrink-0 rounded-full px-1.5 py-0.5 {% if task.status == 'completed' %}bg-emerald-100 text-emerald-700{% elif task.status == 'blocked' %}bg-rose-100 text-rose-700{% elif task.status == 'in_progress' %}bg-blue-100 text-blue-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ (task.status or 'pending').replace('_',' ').title() }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="flex flex-wrap gap-2 text-xs font-medium">
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-emerald-700">✓ Done</span>
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-amber-700">! Blocked</span>
|
||||
<span class="rounded-full bg-red-50 px-3 py-1 text-red-700">✕ N/A</span>
|
||||
<span class="rounded-full bg-indigo-50 px-3 py-1 text-indigo-700">◆ Approval</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<main class="space-y-4">
|
||||
{% set task = board.active_task %}
|
||||
{% if task %}
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ board.active_category }}</div>
|
||||
<h3 class="mt-1 text-lg font-semibold text-slate-900">{{ task.task_name }}</h3>
|
||||
{% if task.description %}<p class="mt-2 whitespace-pre-line text-sm leading-6 text-slate-600">{{ task.description }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-slate-700">Task #{{ task.sequence_no }}</span>
|
||||
{% if task.response_required %}<span class="rounded-full bg-indigo-100 px-3 py-1 text-indigo-700">Response Required</span>{% endif %}
|
||||
{% if task.evidence_required or task.aqmm_evidence_required %}<span class="rounded-full bg-amber-100 px-3 py-1 text-amber-700">Evidence Required</span>{% endif %}
|
||||
{% if task.is_locked or (task.subscription and task.subscription.is_locked) %}<span class="rounded-full bg-slate-200 px-3 py-1 text-slate-700">Locked</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for category in board.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>
|
||||
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/tasks/{{ task.id }}/save" class="mt-6 space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Task Status</label>
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="blocked" {% if task.status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Work Remark / Block Reason</label>
|
||||
<input type="text" name="remarks" value="{{ task.remarks or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Work performed, pending document or hold reason" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||
{% for task in category.tasks %}
|
||||
{% set status = task.i1_status or (task.status or 'pending') %}
|
||||
<div id="task-{{ task.id }}" class="px-4 py-3 sm:px-5">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-base font-bold
|
||||
{% if status == 'completed' %}bg-emerald-100 text-emerald-700
|
||||
{% elif status == 'not_applicable' %}bg-red-100 text-red-700
|
||||
{% elif status == 'blocked' %}bg-amber-100 text-amber-700
|
||||
{% elif status == 'in_progress' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-slate-100 text-slate-500{% endif %}">
|
||||
{% if status == 'completed' %}✓{% elif status == 'not_applicable' %}✕{% elif status == 'blocked' %}!{% elif status == 'in_progress' %}•{% else %}○{% endif %}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-semibold text-slate-400">#{{ task.sequence_no }}</span>
|
||||
<h4 class="font-semibold text-slate-900">{{ task.task_name }}</h4>
|
||||
{% if task.i1_review_label %}
|
||||
{% if task.i1_review_label == 'Approved' %}
|
||||
<span class="rounded-full bg-emerald-50 px-2 py-1 text-[11px] font-semibold text-emerald-700">◆ Approved</span>
|
||||
{% elif task.i1_review_label == 'Returned for correction' %}
|
||||
<span class="rounded-full bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700">↩ Returned</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-indigo-50 px-2 py-1 text-[11px] font-semibold text-indigo-700">◆ {{ task.i1_review_label }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if task.i1_needs_evidence %}
|
||||
<span class="rounded-full bg-violet-50 px-2 py-1 text-[11px] font-semibold text-violet-700">Evidence required</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if task.description %}<p class="mt-1 text-sm text-slate-500">{{ task.description }}</p>{% endif %}
|
||||
{% if task.i1_approval_blocker %}
|
||||
<div class="mt-2 inline-flex items-center gap-2 rounded-lg bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700">
|
||||
🔒 {{ task.i1_approval_blocker.label }} — {{ task.i1_approval_blocker.task_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if status == 'blocked' and task.remarks %}
|
||||
<div class="mt-2 text-sm font-medium text-amber-700">! {{ task.remarks }}</div>
|
||||
{% elif status == 'not_applicable' and task.remarks %}
|
||||
<div class="mt-2 text-sm text-red-600">N/A: {{ task.remarks }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set response_type = task.response_type or 'NONE' %}
|
||||
{% if response_type != 'NONE' or task.response_required %}
|
||||
<div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
|
||||
<div class="mb-4">
|
||||
<h4 class="font-semibold text-indigo-950">Checklist Response</h4>
|
||||
<p class="mt-1 text-xs text-indigo-700">Save a draft at any stage. Required controls are enforced when the task is marked Completed.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
{% if not task.i1_approval_blocker and not task.is_locked and not (task.subscription and task.subscription.is_locked) %}
|
||||
{% if status not in ['completed', 'not_applicable'] %}
|
||||
{% if task.i1_quick_complete %}
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/tasks/{{ task.id }}/quick-action">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="done">
|
||||
<button type="submit" class="rounded-xl bg-emerald-600 px-3 py-2 text-sm font-bold text-white hover:bg-emerald-700" title="Mark Done">✓ Done</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="/documents/tasks/{{ task.id }}" class="rounded-xl border border-violet-200 bg-violet-50 px-3 py-2 text-sm font-semibold text-violet-700 hover:bg-violet-100">
|
||||
{% if task.i1_needs_evidence and not task.i1_has_evidence %}Upload Evidence{% else %}Complete Details{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if response_type in ['YES_NO_NA', 'YES_NO'] %}
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Response{% if task.response_required %} *{% endif %}</label>
|
||||
<select name="checklist_response" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||
<option value="">Select response</option>
|
||||
<option value="YES" {% if task.checklist_response == 'YES' %}selected{% endif %}>Yes</option>
|
||||
<option value="NO" {% if task.checklist_response == 'NO' %}selected{% endif %}>No</option>
|
||||
{% if response_type == 'YES_NO_NA' %}<option value="NA" {% if task.checklist_response == 'NA' %}selected{% endif %}>Not Applicable</option>{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
{% elif response_type == 'TEXT' %}
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Text Response{% if task.response_required %} *{% endif %}</label>
|
||||
<textarea name="checklist_text_response" rows="4" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>{{ task.checklist_text_response or '' }}</textarea>
|
||||
</div>
|
||||
{% elif response_type == 'NUMBER' %}
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Number Response{% if task.response_required %} *{% endif %}</label>
|
||||
<input type="number" step="any" name="checklist_number_response" value="{{ task.checklist_number_response if task.checklist_number_response is not none else '' }}" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||
</div>
|
||||
{% elif response_type == 'DATE' %}
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Date Response{% if task.response_required %} *{% endif %}</label>
|
||||
<input type="date" name="checklist_date_response" value="{{ task.checklist_date_response or '' }}" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>
|
||||
</div>
|
||||
<details class="relative">
|
||||
<summary class="list-none cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-bold text-amber-700 hover:bg-amber-100">! Blocked</summary>
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/tasks/{{ task.id }}/quick-action" class="absolute right-0 z-20 mt-2 w-80 rounded-xl border border-amber-200 bg-white p-3 shadow-xl">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="blocked">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Reason</label>
|
||||
<select name="reason" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="">Select reason</option>
|
||||
{% for code, label in board.hold_reasons.items() %}<option value="{{ label }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="mt-2 w-full rounded-lg bg-amber-600 px-3 py-2 text-sm font-semibold text-white">Mark Blocked</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="relative">
|
||||
<summary class="list-none cursor-pointer rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100">✕ N/A</summary>
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/tasks/{{ task.id }}/quick-action" class="absolute right-0 z-20 mt-2 w-80 rounded-xl border border-red-200 bg-white p-3 shadow-xl">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="na">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Short reason</label>
|
||||
<input type="text" name="reason" maxlength="500" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Why is this not applicable?" required>
|
||||
<button type="submit" class="mt-2 w-full rounded-lg bg-red-600 px-3 py-2 text-sm font-semibold text-white">Mark N/A</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-500">🔒 Locked</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Checklist Remarks{% if task.remarks_required_if_no %} · mandatory for No{% endif %}</label>
|
||||
<textarea name="checklist_remarks" rows="4" class="w-full rounded-xl border border-indigo-200 bg-white px-4 py-2 text-sm" placeholder="Document conclusion, exception, NA rationale or follow-up" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>{{ task.checklist_remarks or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="checklist_response" value="">
|
||||
<input type="hidden" name="checklist_text_response" value="">
|
||||
<input type="hidden" name="checklist_number_response" value="">
|
||||
<input type="hidden" name="checklist_date_response" value="">
|
||||
<input type="hidden" name="checklist_remarks" value="">
|
||||
{% endif %}
|
||||
<details class="group w-full lg:w-auto">
|
||||
<summary class="list-none cursor-pointer rounded-xl border border-slate-300 bg-white px-3 py-2 text-center text-sm font-semibold text-slate-700 hover:bg-slate-50">Details</summary>
|
||||
<div class="mt-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 lg:min-w-[720px]">
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/tasks/{{ task.id }}/save" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="status" value="{{ task.status or 'pending' }}">
|
||||
<input type="hidden" name="workflow_action" value="save">
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900">Evidence and Communication</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">Use the existing document and timeline controls. Uploaded task documents remain linked to this task and engagement.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/documents/tasks/{{ task.id }}" class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50">Open Evidence</a>
|
||||
<a href="/employee/work/tasks/{{ task.id }}/communication" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Communication Timeline{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% set response_type = task.response_type or 'NONE' %}
|
||||
{% if response_type != 'NONE' or task.response_required %}
|
||||
<div class="rounded-xl border border-indigo-200 bg-white p-3">
|
||||
<div class="text-sm font-semibold text-indigo-900">Checklist Response{% if task.response_required %} *{% endif %}</div>
|
||||
{% if response_type in ['YES_NO_NA', 'YES_NO'] %}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<label class="cursor-pointer"><input type="radio" class="peer sr-only" name="checklist_response" value="YES" {% if task.checklist_response == 'YES' %}checked{% endif %}><span class="inline-flex rounded-lg border px-3 py-2 text-sm peer-checked:border-emerald-500 peer-checked:bg-emerald-50 peer-checked:text-emerald-700">Yes</span></label>
|
||||
<label class="cursor-pointer"><input type="radio" class="peer sr-only" name="checklist_response" value="NO" {% if task.checklist_response == 'NO' %}checked{% endif %}><span class="inline-flex rounded-lg border px-3 py-2 text-sm peer-checked:border-red-500 peer-checked:bg-red-50 peer-checked:text-red-700">No</span></label>
|
||||
{% if response_type == 'YES_NO_NA' %}<label class="cursor-pointer"><input type="radio" class="peer sr-only" name="checklist_response" value="NA" {% if task.checklist_response == 'NA' %}checked{% endif %}><span class="inline-flex rounded-lg border px-3 py-2 text-sm peer-checked:border-slate-500 peer-checked:bg-slate-100">N/A</span></label>{% endif %}
|
||||
</div>
|
||||
{% elif response_type == 'TEXT' %}
|
||||
<textarea name="checklist_text_response" rows="2" class="mt-2 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">{{ task.checklist_text_response or '' }}</textarea>
|
||||
{% elif response_type == 'NUMBER' %}
|
||||
<input type="number" step="any" name="checklist_number_response" value="{{ task.checklist_number_response if task.checklist_number_response is not none else '' }}" class="mt-2 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||
{% elif response_type == 'DATE' %}
|
||||
<input type="date" name="checklist_date_response" value="{{ task.checklist_date_response or '' }}" class="mt-2 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||
{% endif %}
|
||||
<textarea name="checklist_remarks" rows="2" class="mt-2 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Checklist remark only when needed">{{ task.checklist_remarks or '' }}</textarea>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="checklist_response" value="">
|
||||
<input type="hidden" name="checklist_text_response" value="">
|
||||
<input type="hidden" name="checklist_number_response" value="">
|
||||
<input type="hidden" name="checklist_date_response" value="">
|
||||
<input type="hidden" name="checklist_remarks" value="">
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-5">
|
||||
<details class="w-full rounded-xl border border-amber-200 bg-amber-50 p-3 text-left">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-amber-900">Escalate dependency or review delay</summary>
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/escalate" class="mt-3 grid gap-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="level" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" required>{% for code, label in board.escalation_levels.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select>
|
||||
<textarea name="message" rows="3" maxlength="1000" required class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" placeholder="Explain the dependency, ageing or review delay"></textarea>
|
||||
<button type="submit" class="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-semibold text-amber-900">Send Escalation</button>
|
||||
</form>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Work Remark</label>
|
||||
<input type="text" name="remarks" value="{{ task.remarks or '' }}" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Optional">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/documents/tasks/{{ task.id }}" class="rounded-lg border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700">Evidence{% if task.i1_has_evidence %} ✓{% endif %}</a>
|
||||
<a href="/employee/work/tasks/{{ task.id }}/communication" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700">Communication{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a>
|
||||
</div>
|
||||
<button type="submit" class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Details</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" name="workflow_action" value="save" class="rounded-xl border border-brand-300 bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save</button>
|
||||
<button type="submit" name="workflow_action" value="save_next" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save & Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-10 text-center text-sm text-slate-500">No assigned workflow task is available.</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Engagement Summary</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Existing engagement details and document controls remain unchanged.</p>
|
||||
</div>
|
||||
<a href="/documents/engagements/{{ board.engagement_id }}" class="text-sm font-semibold text-brand-700 hover:text-brand-800">Open Documents</a>
|
||||
</div>
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Engagement Status</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.status if board.subscription else '-' }}</dd></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Financial Year</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.financial_year if board.subscription else '-' }}</dd></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Due Date</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.current_due_date if board.subscription else '-' }}</dd></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Review Partner</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.review_partner.full_name if board.subscription and board.subscription.review_partner else '-' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-amber-900">Escalate dependency or review delay</summary>
|
||||
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/escalate" class="mt-3 grid gap-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="level" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" required>{% for code, label in board.escalation_levels.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select>
|
||||
<textarea name="message" rows="2" maxlength="1000" required class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" placeholder="Explain the dependency or review delay"></textarea>
|
||||
<button type="submit" class="w-fit rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-semibold text-amber-900">Send Escalation</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Engagement Summary</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Existing engagement, team and document controls remain unchanged.</p>
|
||||
</div>
|
||||
<a href="/documents/engagements/{{ board.engagement_id }}" class="text-sm font-semibold text-brand-700 hover:text-brand-800">Open Documents</a>
|
||||
</div>
|
||||
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Engagement Status</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.status if board.subscription else '-' }}</dd></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Financial Year</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.financial_year if board.subscription else '-' }}</dd></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Due Date</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.current_due_date if board.subscription else '-' }}</dd></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><dt class="text-slate-500">Review Partner</dt><dd class="mt-1 font-medium text-slate-900">{{ board.subscription.review_partner.full_name if board.subscription and board.subscription.review_partner else '-' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+58
-24
@@ -49,6 +49,7 @@ from app.modules.employees.service import (
|
||||
get_employee_engagement_work_board,
|
||||
start_employee_engagement_workflow,
|
||||
save_employee_workflow_task,
|
||||
quick_update_employee_workflow_task,
|
||||
pause_employee_engagement_workflow,
|
||||
resume_employee_engagement_workflow,
|
||||
escalate_employee_engagement_workflow,
|
||||
@@ -325,28 +326,12 @@ def _form_payload(form, *, include_context: bool = False):
|
||||
|
||||
|
||||
def _form_options(db, current_user, scope, *, include_user_id: int | None = None):
|
||||
users = list_linkable_users(db, scope, include_user_id=include_user_id)
|
||||
partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin)
|
||||
|
||||
# A Partner may onboard or link only Staff and Branch Manager users. The
|
||||
# service layer repeats this rule so a forged POST cannot bypass the form.
|
||||
if partner_staff_mode:
|
||||
allowed_roles = {"Staff", "Branch Manager"}
|
||||
prohibited_roles = {"System Admin", "Firm Admin", "Partner"}
|
||||
users = [
|
||||
user
|
||||
for user in users
|
||||
if set(get_user_roles(db, user.id)).intersection(allowed_roles)
|
||||
and not set(get_user_roles(db, user.id)).intersection(prohibited_roles)
|
||||
]
|
||||
|
||||
return {
|
||||
"tenants": visible_tenants(db, current_user),
|
||||
"branches": visible_branches(db, current_user, scope.tenant_id),
|
||||
"users": users,
|
||||
"users": list_linkable_users(db, scope, include_user_id=include_user_id),
|
||||
"managers": list_reporting_managers(db, scope),
|
||||
"scope": scope,
|
||||
"partner_staff_mode": partner_staff_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -579,7 +564,7 @@ def employee_new(request: Request):
|
||||
"modules/employees/templates/employees/form.html",
|
||||
db,
|
||||
current_user,
|
||||
title="Add Staff" if scope.is_partner else "Add Employee",
|
||||
title="Add Employee",
|
||||
employee=None,
|
||||
errors=[],
|
||||
mode="create",
|
||||
@@ -607,11 +592,6 @@ async def employee_create_submit(request: Request):
|
||||
return _redirect_denied()
|
||||
scope = build_employee_scope(db, current_user, tenant_id=form.get("tenant_id"), branch_id=form.get("branch_id"))
|
||||
payload = _form_payload(form, include_context=True)
|
||||
if scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin:
|
||||
requested_role = (payload.get("employee_role") or "Staff").strip()
|
||||
payload["employee_role"] = requested_role if requested_role in {"Staff", "Branch Manager"} else "Staff"
|
||||
payload["tenant_id"] = scope.tenant_id
|
||||
payload["branch_id"] = scope.branch_id or current_user.branch_id
|
||||
try:
|
||||
emp = create_employee(db, current_user, scope, payload)
|
||||
return RedirectResponse(url=f"/employees/{emp.id}", status_code=303)
|
||||
@@ -621,7 +601,7 @@ async def employee_create_submit(request: Request):
|
||||
"modules/employees/templates/employees/form.html",
|
||||
db,
|
||||
current_user,
|
||||
title="Add Staff" if scope.is_partner else "Add Employee",
|
||||
title="Add Employee",
|
||||
employee=payload,
|
||||
errors=[getattr(exc, "detail", str(exc))],
|
||||
mode="create",
|
||||
@@ -2222,6 +2202,60 @@ def employee_workflow_escalate(
|
||||
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?escalated=1", status_code=303)
|
||||
|
||||
|
||||
@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/quick-action")
|
||||
def employee_workflow_task_quick_action(
|
||||
request: Request,
|
||||
engagement_id: int,
|
||||
task_id: int,
|
||||
action: str = Form(...),
|
||||
reason: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
try:
|
||||
require_permission(db, current_user, "employees.work.view_self")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
try:
|
||||
validate_csrf(request, csrf_token)
|
||||
except PermissionError:
|
||||
return _csrf_rejected(request)
|
||||
|
||||
tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id
|
||||
branch_id = request.session.get("active_branch_id")
|
||||
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
|
||||
|
||||
try:
|
||||
saved_task, next_task = quick_update_employee_workflow_task(
|
||||
db,
|
||||
scope,
|
||||
task_id,
|
||||
action=action,
|
||||
reason=reason,
|
||||
actor_user_id=current_user.id,
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
message = str(exc).replace(" ", "+")[:240]
|
||||
return RedirectResponse(
|
||||
url=f"/employee/work/engagements/{engagement_id}?task_id={task_id}&quick_error={message}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
target_task = next_task or saved_task
|
||||
return RedirectResponse(
|
||||
url=f"/employee/work/engagements/{engagement_id}?task_id={target_task.id}&quick_saved=1",
|
||||
status_code=303,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save")
|
||||
def employee_workflow_task_save(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user