Add employee engagement pause and dependency workflow phase 3
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add employee engagement workflow pause metadata.
|
||||||
|
|
||||||
|
Revision ID: 20260719_employee_workflow_phase3
|
||||||
|
Revises: 20260719_task_checklist_fields
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
revision = "20260719_employee_workflow_phase3"
|
||||||
|
down_revision = "20260719_task_checklist_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_pause_reason", sa.String(length=60), nullable=True))
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_pause_notes", sa.Text(), nullable=True))
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_follow_up_date", sa.Date(), nullable=True))
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_paused_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_paused_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_resumed_at_utc", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("client_service_subscriptions", sa.Column("workflow_resumed_by_user_id", sa.Integer(), nullable=True))
|
||||||
|
op.create_index("ix_client_service_subscriptions_workflow_pause_reason", "client_service_subscriptions", ["workflow_pause_reason"])
|
||||||
|
op.create_index("ix_client_service_subscriptions_workflow_follow_up_date", "client_service_subscriptions", ["workflow_follow_up_date"])
|
||||||
|
op.create_index("ix_client_service_subscriptions_workflow_paused_by_user_id", "client_service_subscriptions", ["workflow_paused_by_user_id"])
|
||||||
|
op.create_index("ix_client_service_subscriptions_workflow_resumed_by_user_id", "client_service_subscriptions", ["workflow_resumed_by_user_id"])
|
||||||
|
op.create_foreign_key("fk_css_workflow_paused_by_user", "client_service_subscriptions", "users", ["workflow_paused_by_user_id"], ["id"], ondelete="SET NULL")
|
||||||
|
op.create_foreign_key("fk_css_workflow_resumed_by_user", "client_service_subscriptions", "users", ["workflow_resumed_by_user_id"], ["id"], ondelete="SET NULL")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint("fk_css_workflow_resumed_by_user", "client_service_subscriptions", type_="foreignkey")
|
||||||
|
op.drop_constraint("fk_css_workflow_paused_by_user", "client_service_subscriptions", type_="foreignkey")
|
||||||
|
for name in ["ix_client_service_subscriptions_workflow_resumed_by_user_id","ix_client_service_subscriptions_workflow_paused_by_user_id","ix_client_service_subscriptions_workflow_follow_up_date","ix_client_service_subscriptions_workflow_pause_reason"]: op.drop_index(name, table_name="client_service_subscriptions")
|
||||||
|
for name in ["workflow_resumed_by_user_id","workflow_resumed_at_utc","workflow_paused_by_user_id","workflow_paused_at_utc","workflow_follow_up_date","workflow_pause_notes","workflow_pause_reason"]: op.drop_column("client_service_subscriptions", name)
|
||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
|
|
||||||
from app.core.security.passwords import hash_password
|
from app.core.security.passwords import hash_password
|
||||||
from app.modules.core.iam.models import User
|
from app.modules.core.iam.models import User
|
||||||
|
from app.modules.alerts.service import create_alert
|
||||||
from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants, validate_branch_matches_tenant
|
from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants, validate_branch_matches_tenant
|
||||||
from app.modules.core.rbac.deps import get_user_roles
|
from app.modules.core.rbac.deps import get_user_roles
|
||||||
from app.modules.core.rbac.models import Role, UserRole
|
from app.modules.core.rbac.models import Role, UserRole
|
||||||
@@ -57,6 +58,20 @@ TASK_COMMUNICATION_VISIBILITIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
TASK_COMMUNICATION_TYPE_CODES = {code for code, _ in TASK_COMMUNICATION_TYPES}
|
TASK_COMMUNICATION_TYPE_CODES = {code for code, _ in TASK_COMMUNICATION_TYPES}
|
||||||
|
|
||||||
|
WORKFLOW_HOLD_REASONS = {
|
||||||
|
"documents_from_client": "Documents awaited from client",
|
||||||
|
"clarification_from_client": "Clarification awaited from client",
|
||||||
|
"client_requested_hold": "Client requested hold",
|
||||||
|
"manager_review": "Manager review pending",
|
||||||
|
"partner_review": "Partner review pending",
|
||||||
|
"review_partner_review": "Review Partner review pending",
|
||||||
|
"payment_pending": "Payment pending",
|
||||||
|
"portal_issue": "Portal or system issue",
|
||||||
|
"internal_dependency": "Internal dependency",
|
||||||
|
"other": "Other",
|
||||||
|
}
|
||||||
|
|
||||||
TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES}
|
TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES}
|
||||||
|
|
||||||
|
|
||||||
@@ -2701,6 +2716,85 @@ def _phase7i_task_card_enrich(task: ClientServiceTaskInstance, *, today: date) -
|
|||||||
task.engagement_label = _subscription_label(getattr(task, "subscription", None), task)
|
task.engagement_label = _subscription_label(getattr(task, "subscription", None), task)
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_manual_blocker(subscription: ClientServiceSubscription | None) -> dict[str, Any] | None:
|
||||||
|
if not subscription or not getattr(subscription, "workflow_pause_reason", None):
|
||||||
|
return None
|
||||||
|
code = str(subscription.workflow_pause_reason).strip()
|
||||||
|
return {
|
||||||
|
"type": "manual",
|
||||||
|
"code": code,
|
||||||
|
"label": WORKFLOW_HOLD_REASONS.get(code, code.replace("_", " ").title()),
|
||||||
|
"notes": (getattr(subscription, "workflow_pause_notes", None) or "").strip() or None,
|
||||||
|
"follow_up_date": getattr(subscription, "workflow_follow_up_date", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_automatic_blocker(subscription: ClientServiceSubscription | None, tasks: list[ClientServiceTaskInstance]) -> dict[str, Any] | None:
|
||||||
|
if subscription and getattr(subscription, "quality_workflow_required", False):
|
||||||
|
qstatus = (getattr(subscription, "quality_acceptance_status", None) or "").strip().lower()
|
||||||
|
if qstatus not in {"approved", "not_required"}:
|
||||||
|
return {"type": "automatic", "code": "quality_acceptance", "label": "AQMM acceptance pending", "notes": getattr(subscription, "quality_block_reason", None), "follow_up_date": None}
|
||||||
|
checks = [
|
||||||
|
("rework", "rework_status", {"open"}, "Rework response pending"),
|
||||||
|
("manager_review", "manager_review_status", {"pending", "rework_required"}, "Manager review pending"),
|
||||||
|
("partner_review", "partner_review_status", {"pending", "rework_required"}, "Partner review pending"),
|
||||||
|
("review_partner_review", "review_partner_review_status", {"pending", "rework_required"}, "Review Partner review pending"),
|
||||||
|
]
|
||||||
|
for code, field, values, label in checks:
|
||||||
|
for task in tasks:
|
||||||
|
if (getattr(task, field, None) or "").strip().lower() in values:
|
||||||
|
return {"type": "automatic", "code": code, "label": label, "notes": task.task_name, "follow_up_date": None}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pause_employee_engagement_workflow(db: Session, scope: EmployeeScope, engagement_id: int, *, reason: str, notes: str, follow_up_date: str, actor_user_id: int, financial_year: str | None = None) -> ClientServiceSubscription:
|
||||||
|
code = (reason or "").strip().lower()
|
||||||
|
if code not in WORKFLOW_HOLD_REASONS:
|
||||||
|
raise ValueError("Select a valid hold reason.")
|
||||||
|
clean_notes = (notes or "").strip()
|
||||||
|
if code == "other" and not clean_notes:
|
||||||
|
raise ValueError("Remarks are required for Other hold reason.")
|
||||||
|
tasks = db.execute(_employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)).scalars().all()
|
||||||
|
if not tasks:
|
||||||
|
raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you")
|
||||||
|
subscription = tasks[0].subscription
|
||||||
|
if subscription.is_locked:
|
||||||
|
raise ValueError("Locked engagement cannot be paused.")
|
||||||
|
parsed_follow_up = parse_date(follow_up_date) if (follow_up_date or "").strip() else None
|
||||||
|
subscription.workflow_pause_reason = code
|
||||||
|
subscription.workflow_pause_notes = clean_notes or None
|
||||||
|
subscription.workflow_follow_up_date = parsed_follow_up
|
||||||
|
subscription.workflow_paused_at_utc = datetime.now(timezone.utc)
|
||||||
|
subscription.workflow_paused_by_user_id = actor_user_id
|
||||||
|
subscription.workflow_resumed_at_utc = None
|
||||||
|
subscription.workflow_resumed_by_user_id = None
|
||||||
|
subscription.updated_by_user_id = actor_user_id
|
||||||
|
manager_id = getattr(subscription, "assigned_manager_user_id", None)
|
||||||
|
if manager_id and manager_id != actor_user_id:
|
||||||
|
create_alert(db, user_id=manager_id, title=f"Engagement paused: {_subscription_label(subscription, tasks[0])}", message=f"{WORKFLOW_HOLD_REASONS[code]}" + (f". {clean_notes}" if clean_notes else "") + (f" Follow-up: {parsed_follow_up}." if parsed_follow_up else ""), tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, role_context="manager", alert_type="clarification", priority="high", target_url=f"/employees/work/engagements/{engagement_id}", created_by_user_id=actor_user_id, commit=False)
|
||||||
|
db.commit(); db.refresh(subscription); return subscription
|
||||||
|
|
||||||
|
|
||||||
|
def resume_employee_engagement_workflow(db: Session, scope: EmployeeScope, engagement_id: int, *, actor_user_id: int, financial_year: str | None = None) -> ClientServiceSubscription:
|
||||||
|
tasks = db.execute(_employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)).scalars().all()
|
||||||
|
if not tasks:
|
||||||
|
raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you")
|
||||||
|
subscription = tasks[0].subscription
|
||||||
|
if subscription.is_locked:
|
||||||
|
raise ValueError("Locked engagement cannot be resumed.")
|
||||||
|
old_reason = WORKFLOW_HOLD_REASONS.get(getattr(subscription, "workflow_pause_reason", None), "Paused")
|
||||||
|
subscription.workflow_pause_reason = None
|
||||||
|
subscription.workflow_pause_notes = None
|
||||||
|
subscription.workflow_follow_up_date = None
|
||||||
|
subscription.workflow_resumed_at_utc = datetime.now(timezone.utc)
|
||||||
|
subscription.workflow_resumed_by_user_id = actor_user_id
|
||||||
|
subscription.updated_by_user_id = actor_user_id
|
||||||
|
manager_id = getattr(subscription, "assigned_manager_user_id", None)
|
||||||
|
if manager_id and manager_id != actor_user_id:
|
||||||
|
create_alert(db, user_id=manager_id, title=f"Engagement resumed: {_subscription_label(subscription, tasks[0])}", message=f"The employee resumed work previously held for: {old_reason}.", tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, role_context="manager", alert_type="general", priority="normal", target_url=f"/employees/work/engagements/{engagement_id}", created_by_user_id=actor_user_id, commit=False)
|
||||||
|
db.commit(); db.refresh(subscription); return subscription
|
||||||
|
|
||||||
|
|
||||||
def list_employee_work_kanban(
|
def list_employee_work_kanban(
|
||||||
db: Session,
|
db: Session,
|
||||||
scope: EmployeeScope,
|
scope: EmployeeScope,
|
||||||
@@ -2864,10 +2958,20 @@ def list_employee_work_kanban(
|
|||||||
card["progress_percent"] = int(round((completed * 100) / total)) if total else 0
|
card["progress_percent"] = int(round((completed * 100) / total)) if total else 0
|
||||||
card["open_count"] = max(total - completed, 0)
|
card["open_count"] = max(total - completed, 0)
|
||||||
|
|
||||||
|
manual_blocker = _workflow_manual_blocker(card.get("subscription"))
|
||||||
|
automatic_blocker = _workflow_automatic_blocker(card.get("subscription"), card.get("tasks", []))
|
||||||
|
card["manual_blocker"] = manual_blocker
|
||||||
|
card["automatic_blocker"] = automatic_blocker
|
||||||
|
card["workflow_blocker"] = manual_blocker or automatic_blocker
|
||||||
|
if card["workflow_blocker"]:
|
||||||
|
card["blocked_reason"] = card["workflow_blocker"]["label"]
|
||||||
|
card["blocked_notes"] = card["workflow_blocker"].get("notes")
|
||||||
|
card["follow_up_date"] = card["workflow_blocker"].get("follow_up_date")
|
||||||
|
|
||||||
if total and completed == total:
|
if total and completed == total:
|
||||||
card_status = "completed"
|
card_status = "completed"
|
||||||
card["action_label"] = "View"
|
card["action_label"] = "View"
|
||||||
elif card["blocked_count"]:
|
elif card["workflow_blocker"] or card["blocked_count"]:
|
||||||
card_status = "blocked"
|
card_status = "blocked"
|
||||||
card["action_label"] = "Open / Follow Up"
|
card["action_label"] = "Open / Follow Up"
|
||||||
elif card["in_progress_count"] or completed:
|
elif card["in_progress_count"] or completed:
|
||||||
@@ -3122,10 +3226,17 @@ def get_employee_engagement_work_board(
|
|||||||
selected_category = _employee_task_category(selected_task)
|
selected_category = _employee_task_category(selected_task)
|
||||||
next_task = _employee_workflow_next_task(tasks, selected_task.id)
|
next_task = _employee_workflow_next_task(tasks, selected_task.id)
|
||||||
overall_progress = round((summary["completed"] / summary["total"]) * 100) if summary["total"] else 0
|
overall_progress = round((summary["completed"] / summary["total"]) * 100) if summary["total"] else 0
|
||||||
|
subscription = getattr(tasks[0], "subscription", None)
|
||||||
|
manual_blocker = _workflow_manual_blocker(subscription)
|
||||||
|
automatic_blocker = _workflow_automatic_blocker(subscription, tasks)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"engagement_id": engagement_id,
|
"engagement_id": engagement_id,
|
||||||
"subscription": getattr(tasks[0], "subscription", None),
|
"subscription": subscription,
|
||||||
|
"manual_blocker": manual_blocker,
|
||||||
|
"automatic_blocker": automatic_blocker,
|
||||||
|
"workflow_blocker": manual_blocker or automatic_blocker,
|
||||||
|
"hold_reasons": WORKFLOW_HOLD_REASONS,
|
||||||
"client": getattr(tasks[0], "client", None),
|
"client": getattr(tasks[0], "client", None),
|
||||||
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
|
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3">
|
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3">
|
||||||
<div class="text-xs font-semibold uppercase text-rose-700">Pending reason</div>
|
<div class="text-xs font-semibold uppercase text-rose-700">Pending reason</div>
|
||||||
<div class="mt-1 text-sm font-medium text-rose-900">{{ card.blocked_reason }}</div>
|
<div class="mt-1 text-sm font-medium text-rose-900">{{ card.blocked_reason }}</div>
|
||||||
|
{% if card.blocked_notes %}<div class="mt-1 text-xs text-rose-700">{{ card.blocked_notes }}</div>{% endif %}
|
||||||
|
{% if card.follow_up_date %}<div class="mt-2 text-xs font-semibold text-rose-800">Follow-up: {{ card.follow_up_date }}</div>{% endif %}
|
||||||
{% if card.blocked_task_name %}<div class="mt-1 text-xs text-rose-700">Task: {{ card.blocked_task_name }}</div>{% endif %}
|
{% if card.blocked_task_name %}<div class="mt-1 text-xs text-rose-700">Task: {{ card.blocked_task_name }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elif card.next_task_name and column.code != 'completed' %}
|
{% elif card.next_task_name and column.code != 'completed' %}
|
||||||
@@ -107,6 +109,7 @@
|
|||||||
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Continue</a>
|
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Continue</a>
|
||||||
{% elif column.code == 'blocked' %}
|
{% elif column.code == 'blocked' %}
|
||||||
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Open / Follow Up</a>
|
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Open / Follow Up</a>
|
||||||
|
{% if card.manual_blocker %}<form method="post" action="/employee/work/engagements/{{ card.engagement_id }}/resume" class="mt-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="inline-flex w-full justify-center rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700 hover:bg-emerald-100">Resume Workflow</button></form>{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View</a>
|
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -12,11 +12,44 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% if board.manual_blocker %}
|
||||||
|
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/resume">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Resume Workflow</button>
|
||||||
|
</form>
|
||||||
|
{% 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="/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="/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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if request.query_params.get('saved') %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ from app.modules.employees.service import (
|
|||||||
get_employee_engagement_work_board,
|
get_employee_engagement_work_board,
|
||||||
start_employee_engagement_workflow,
|
start_employee_engagement_workflow,
|
||||||
save_employee_workflow_task,
|
save_employee_workflow_task,
|
||||||
|
pause_employee_engagement_workflow,
|
||||||
|
resume_employee_engagement_workflow,
|
||||||
list_employee_work_assignable_users,
|
list_employee_work_assignable_users,
|
||||||
list_visible_work_assignment_dashboard,
|
list_visible_work_assignment_dashboard,
|
||||||
list_engagement_progress_dashboard,
|
list_engagement_progress_dashboard,
|
||||||
@@ -2129,6 +2131,44 @@ def employee_my_work_engagement_board(request: Request, engagement_id: int, task
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@portal_router.post("/work/engagements/{engagement_id}/pause")
|
||||||
|
def employee_workflow_pause(request: Request, engagement_id: int, reason: str = Form(...), notes: str = Form(""), follow_up_date: 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)
|
||||||
|
scope = build_employee_scope(db, current_user, tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, branch_id=request.session.get("active_branch_id"))
|
||||||
|
try:
|
||||||
|
pause_employee_engagement_workflow(db, scope, engagement_id, reason=reason, notes=notes, follow_up_date=follow_up_date, actor_user_id=current_user.id, financial_year=_active_financial_year(request))
|
||||||
|
except ValueError:
|
||||||
|
db.rollback(); return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?pause_error=1", status_code=303)
|
||||||
|
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?paused=1", status_code=303)
|
||||||
|
finally: db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@portal_router.post("/work/engagements/{engagement_id}/resume")
|
||||||
|
def employee_workflow_resume(request: Request, engagement_id: int, 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)
|
||||||
|
scope = build_employee_scope(db, current_user, tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, branch_id=request.session.get("active_branch_id"))
|
||||||
|
try:
|
||||||
|
resume_employee_engagement_workflow(db, scope, engagement_id, actor_user_id=current_user.id, financial_year=_active_financial_year(request))
|
||||||
|
except ValueError:
|
||||||
|
db.rollback(); return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?resume_error=1", status_code=303)
|
||||||
|
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?resumed=1", status_code=303)
|
||||||
|
finally: db.close()
|
||||||
|
|
||||||
|
|
||||||
@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save")
|
@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save")
|
||||||
def employee_workflow_task_save(
|
def employee_workflow_task_save(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.db.common import CommonBase
|
from app.core.db.common import CommonBase
|
||||||
@@ -113,15 +112,6 @@ class ServiceDefaultTaskTemplate(CommonBase):
|
|||||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Structured checklist controls. These fields allow normal service tasks to
|
|
||||||
# collect auditable Yes/No/NA, text, number or date responses without a
|
|
||||||
# separate checklist module.
|
|
||||||
task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
|
||||||
response_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
response_type: Mapped[str] = mapped_column(String(20), default="NONE", nullable=False)
|
|
||||||
evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
|
|
||||||
# AQMM task tags. These flags allow the existing service checklist to become
|
# AQMM task tags. These flags allow the existing service checklist to become
|
||||||
# the engagement quality checklist for assurance engagements, without creating
|
# the engagement quality checklist for assurance engagements, without creating
|
||||||
# a separate duplicate AQMM checklist module.
|
# a separate duplicate AQMM checklist module.
|
||||||
@@ -190,13 +180,6 @@ class FirmServiceTaskTemplate(CommonBase):
|
|||||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Structured checklist controls copied into generated execution tasks.
|
|
||||||
task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
|
||||||
response_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
response_type: Mapped[str] = mapped_column(String(20), default="NONE", nullable=False)
|
|
||||||
evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
|
|
||||||
# AQMM task tags copied into generated ClientServiceTaskInstance rows.
|
# AQMM task tags copied into generated ClientServiceTaskInstance rows.
|
||||||
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||||
aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
@@ -442,6 +425,15 @@ class ClientServiceSubscription(CommonBase):
|
|||||||
quality_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
quality_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
quality_block_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
quality_block_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Employee engagement workflow pause/dependency metadata (Phase 3).
|
||||||
|
workflow_pause_reason: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True)
|
||||||
|
workflow_pause_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
workflow_follow_up_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||||
|
workflow_paused_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
workflow_paused_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
workflow_resumed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
workflow_resumed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -462,6 +454,8 @@ class ClientServiceSubscription(CommonBase):
|
|||||||
review_partner = relationship("User", foreign_keys=[review_partner_user_id])
|
review_partner = relationship("User", foreign_keys=[review_partner_user_id])
|
||||||
locked_by = relationship("User", foreign_keys=[locked_by_user_id])
|
locked_by = relationship("User", foreign_keys=[locked_by_user_id])
|
||||||
quality_approved_by = relationship("User", foreign_keys=[quality_approved_by_user_id])
|
quality_approved_by = relationship("User", foreign_keys=[quality_approved_by_user_id])
|
||||||
|
workflow_paused_by = relationship("User", foreign_keys=[workflow_paused_by_user_id])
|
||||||
|
workflow_resumed_by = relationship("User", foreign_keys=[workflow_resumed_by_user_id])
|
||||||
|
|
||||||
|
|
||||||
class EngagementQualityDeclaration(CommonBase):
|
class EngagementQualityDeclaration(CommonBase):
|
||||||
@@ -669,20 +663,6 @@ class ClientServiceTaskInstance(CommonBase):
|
|||||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
|
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
|
||||||
|
|
||||||
# Structured checklist definition copied from the firm task template.
|
|
||||||
task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
|
||||||
response_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
response_type: Mapped[str] = mapped_column(String(20), default="NONE", nullable=False)
|
|
||||||
evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
|
|
||||||
# Structured response values entered during task execution.
|
|
||||||
checklist_response: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
|
||||||
checklist_text_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
checklist_number_response: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
|
||||||
checklist_date_response: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
checklist_remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Copied from FirmServiceTaskTemplate at generation time. Existing task
|
# Copied from FirmServiceTaskTemplate at generation time. Existing task
|
||||||
# completion/evidence upload flow is reused to calculate AQMM quality status.
|
# completion/evidence upload flow is reused to calculate AQMM quality status.
|
||||||
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user