Add engagement-level employee workflow board phase 1
This commit is contained in:
@@ -2703,113 +2703,278 @@ def list_employee_work_kanban(
|
|||||||
status: str = "open",
|
status: str = "open",
|
||||||
financial_year: str | None = None,
|
financial_year: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return staff self-work as engagement cards in kanban columns.
|
"""Return exactly one employee-board card per assigned engagement.
|
||||||
|
|
||||||
Phase 7I does not introduce a new task table. It reuses existing
|
The task engine remains unchanged. The board derives an engagement state from
|
||||||
client_service_task_instances and groups assigned tasks by engagement/service
|
the staff member's assigned tasks using this precedence:
|
||||||
subscription so staff can open one engagement board and work through tasks.
|
|
||||||
|
completed -> all assigned tasks are closed
|
||||||
|
blocked -> at least one assigned task is blocked
|
||||||
|
in_progress -> at least one assigned task is started/completed
|
||||||
|
pending -> no assigned task has started
|
||||||
"""
|
"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
status_filter = (status or "open").strip().lower()
|
status_filter = (status or "open").strip().lower()
|
||||||
stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year)
|
valid_filters = {"open", "closed", "pending", "in_progress", "blocked", "completed"}
|
||||||
|
if status_filter not in valid_filters:
|
||||||
if status_filter == "open":
|
status_filter = "open"
|
||||||
stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)))
|
|
||||||
elif status_filter == "closed":
|
|
||||||
stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES)))
|
|
||||||
elif status_filter in {code for code, _ in TASK_STATUSES}:
|
|
||||||
stmt = stmt.where(ClientServiceTaskInstance.status == status_filter)
|
|
||||||
|
|
||||||
|
stmt = _employee_work_task_query(
|
||||||
|
db,
|
||||||
|
scope,
|
||||||
|
assigned_only=True,
|
||||||
|
financial_year=financial_year,
|
||||||
|
)
|
||||||
if q.strip():
|
if q.strip():
|
||||||
like = f"%{q.strip()}%"
|
like = f"%{q.strip()}%"
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
ClientServiceTaskInstance.task_name.ilike(like),
|
ClientServiceTaskInstance.task_name.ilike(like),
|
||||||
ClientServiceTaskInstance.description.ilike(like),
|
ClientServiceTaskInstance.description.ilike(like),
|
||||||
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
|
ClientServiceTaskInstance.client.has(
|
||||||
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
|
or_(Client.client_name.ilike(like), Client.client_code.ilike(like))
|
||||||
|
),
|
||||||
|
ClientServiceTaskInstance.catalogue.has(
|
||||||
|
or_(
|
||||||
|
ServiceCatalogue.service_name.ilike(like),
|
||||||
|
ServiceCatalogue.service_code.ilike(like),
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
tasks = db.execute(
|
tasks = db.execute(
|
||||||
stmt.order_by(
|
stmt.order_by(
|
||||||
|
ClientServiceTaskInstance.subscription_id.asc(),
|
||||||
|
ClientServiceTaskInstance.sequence_no.asc(),
|
||||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||||
ClientServiceTaskInstance.priority.desc(),
|
ClientServiceTaskInstance.id.asc(),
|
||||||
ClientServiceTaskInstance.sequence_no.asc(),
|
|
||||||
ClientServiceTaskInstance.id.desc(),
|
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
summary = {"total": len(tasks), "open": 0, "pending": 0, "in_progress": 0, "blocked": 0, "completed": 0, "overdue": 0, "due_today": 0}
|
|
||||||
columns = [
|
columns = [
|
||||||
{"code": "pending", "label": "Pending", "cards": []},
|
{"code": "pending", "label": "Pending", "cards": []},
|
||||||
{"code": "in_progress", "label": "In Progress", "cards": []},
|
{"code": "in_progress", "label": "In Progress", "cards": []},
|
||||||
{"code": "blocked", "label": "Blocked", "cards": []},
|
{"code": "blocked", "label": "Blocked", "cards": []},
|
||||||
{"code": "completed", "label": "Completed", "cards": []},
|
{"code": "completed", "label": "Completed", "cards": []},
|
||||||
]
|
]
|
||||||
column_lookup = {c["code"]: c for c in columns}
|
column_lookup = {column["code"]: column for column in columns}
|
||||||
card_lookup: dict[tuple[str, int], dict[str, Any]] = {}
|
engagement_lookup: dict[int, dict[str, Any]] = {}
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
_phase7i_task_card_enrich(task, today=today)
|
_phase7i_task_card_enrich(task, today=today)
|
||||||
status_code = (task.status or "pending").strip().lower()
|
|
||||||
is_closed = status_code in CLOSED_TASK_STATUSES
|
|
||||||
summary["completed" if is_closed else "open"] += 1
|
|
||||||
if status_code in summary:
|
|
||||||
summary[status_code] += 1
|
|
||||||
if task.is_overdue:
|
|
||||||
summary["overdue"] += 1
|
|
||||||
if task.is_due_today:
|
|
||||||
summary["due_today"] += 1
|
|
||||||
|
|
||||||
column_code = "completed" if is_closed else status_code
|
|
||||||
if column_code not in column_lookup:
|
|
||||||
column_code = "pending"
|
|
||||||
subscription = getattr(task, "subscription", None)
|
subscription = getattr(task, "subscription", None)
|
||||||
engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0
|
engagement_id = (
|
||||||
card_key = (column_code, engagement_id)
|
getattr(subscription, "id", None)
|
||||||
if card_key not in card_lookup:
|
or getattr(task, "subscription_id", 0)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if engagement_id not in engagement_lookup:
|
||||||
client = getattr(task, "client", None)
|
client = getattr(task, "client", None)
|
||||||
card = {
|
engagement_lookup[engagement_id] = {
|
||||||
"engagement_id": engagement_id,
|
"engagement_id": engagement_id,
|
||||||
"subscription": subscription,
|
"subscription": subscription,
|
||||||
"label": _subscription_label(subscription, task),
|
"label": _subscription_label(subscription, task),
|
||||||
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
||||||
"client_code": getattr(client, "client_code", None) or "",
|
"client_code": getattr(client, "client_code", None) or "",
|
||||||
"service_name": getattr(getattr(subscription, "catalogue", None), "service_name", None) or getattr(getattr(task, "catalogue", None), "service_name", None) or "Service Engagement",
|
"service_name": (
|
||||||
"financial_year": getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "-",
|
getattr(getattr(subscription, "catalogue", None), "service_name", None)
|
||||||
"due_date": getattr(subscription, "current_due_date", None) if subscription else getattr(task, "internal_target_date", None),
|
or getattr(getattr(task, "catalogue", None), "service_name", None)
|
||||||
"status": getattr(subscription, "status", None) or "active",
|
or "Service Engagement"
|
||||||
|
),
|
||||||
|
"financial_year": (
|
||||||
|
getattr(subscription, "financial_year", None)
|
||||||
|
or getattr(task, "financial_year", None)
|
||||||
|
or "-"
|
||||||
|
),
|
||||||
|
"due_date": (
|
||||||
|
getattr(subscription, "current_due_date", None)
|
||||||
|
if subscription
|
||||||
|
else getattr(task, "internal_target_date", None)
|
||||||
|
),
|
||||||
"task_count": 0,
|
"task_count": 0,
|
||||||
"open_count": 0,
|
"pending_count": 0,
|
||||||
"completed_count": 0,
|
"in_progress_count": 0,
|
||||||
"blocked_count": 0,
|
"blocked_count": 0,
|
||||||
|
"completed_count": 0,
|
||||||
"overdue_count": 0,
|
"overdue_count": 0,
|
||||||
"due_today_count": 0,
|
"due_today_count": 0,
|
||||||
"latest_comment": None,
|
"latest_comment": None,
|
||||||
|
"blocked_reason": None,
|
||||||
|
"blocked_task_name": None,
|
||||||
|
"next_task_name": None,
|
||||||
"tasks": [],
|
"tasks": [],
|
||||||
}
|
}
|
||||||
card_lookup[card_key] = card
|
|
||||||
column_lookup[column_code]["cards"].append(card)
|
card = engagement_lookup[engagement_id]
|
||||||
card = card_lookup[card_key]
|
task_status = (task.status or "pending").strip().lower()
|
||||||
|
is_closed = task_status in CLOSED_TASK_STATUSES
|
||||||
card["task_count"] += 1
|
card["task_count"] += 1
|
||||||
card["tasks"].append(task)
|
card["tasks"].append(task)
|
||||||
if is_closed:
|
if is_closed:
|
||||||
card["completed_count"] += 1
|
card["completed_count"] += 1
|
||||||
else:
|
elif task_status == "blocked":
|
||||||
card["open_count"] += 1
|
|
||||||
if status_code == "blocked":
|
|
||||||
card["blocked_count"] += 1
|
card["blocked_count"] += 1
|
||||||
|
if not card["blocked_reason"]:
|
||||||
|
card["blocked_reason"] = (
|
||||||
|
(getattr(task, "remarks", None) or "").strip()
|
||||||
|
or (
|
||||||
|
getattr(task.latest_comment, "message", None)
|
||||||
|
if task.latest_comment
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
or "Work is awaiting a dependency or clarification."
|
||||||
|
)
|
||||||
|
card["blocked_task_name"] = task.task_name
|
||||||
|
elif task_status == "in_progress":
|
||||||
|
card["in_progress_count"] += 1
|
||||||
|
else:
|
||||||
|
card["pending_count"] += 1
|
||||||
|
|
||||||
if task.is_overdue:
|
if task.is_overdue:
|
||||||
card["overdue_count"] += 1
|
card["overdue_count"] += 1
|
||||||
if task.is_due_today:
|
if task.is_due_today:
|
||||||
card["due_today_count"] += 1
|
card["due_today_count"] += 1
|
||||||
if task.latest_comment and not card.get("latest_comment"):
|
if task.latest_comment and not card["latest_comment"]:
|
||||||
card["latest_comment"] = task.latest_comment
|
card["latest_comment"] = task.latest_comment
|
||||||
|
if not is_closed and not card["next_task_name"]:
|
||||||
|
card["next_task_name"] = task.task_name
|
||||||
|
|
||||||
return {"summary": summary, "columns": columns, "q": q, "status": status_filter, "today": today}
|
summary = {
|
||||||
|
"total": 0,
|
||||||
|
"open": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"in_progress": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"overdue": 0,
|
||||||
|
"due_today": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for card in engagement_lookup.values():
|
||||||
|
total = card["task_count"]
|
||||||
|
completed = card["completed_count"]
|
||||||
|
card["progress_percent"] = int(round((completed * 100) / total)) if total else 0
|
||||||
|
card["open_count"] = max(total - completed, 0)
|
||||||
|
|
||||||
|
if total and completed == total:
|
||||||
|
card_status = "completed"
|
||||||
|
card["action_label"] = "View"
|
||||||
|
elif card["blocked_count"]:
|
||||||
|
card_status = "blocked"
|
||||||
|
card["action_label"] = "Open / Follow Up"
|
||||||
|
elif card["in_progress_count"] or completed:
|
||||||
|
card_status = "in_progress"
|
||||||
|
card["action_label"] = "Continue"
|
||||||
|
else:
|
||||||
|
card_status = "pending"
|
||||||
|
card["action_label"] = "Start"
|
||||||
|
|
||||||
|
card["workflow_status"] = card_status
|
||||||
|
card["is_overdue"] = bool(card["overdue_count"])
|
||||||
|
card["is_due_today"] = bool(card["due_today_count"])
|
||||||
|
|
||||||
|
include = (
|
||||||
|
status_filter == card_status
|
||||||
|
or (status_filter == "open" and card_status != "completed")
|
||||||
|
or (status_filter == "closed" and card_status == "completed")
|
||||||
|
)
|
||||||
|
if not include:
|
||||||
|
continue
|
||||||
|
|
||||||
|
column_lookup[card_status]["cards"].append(card)
|
||||||
|
summary["total"] += 1
|
||||||
|
summary[card_status] += 1
|
||||||
|
summary["completed" if card_status == "completed" else "open"] += 1
|
||||||
|
if card["is_overdue"]:
|
||||||
|
summary["overdue"] += 1
|
||||||
|
if card["is_due_today"]:
|
||||||
|
summary["due_today"] += 1
|
||||||
|
|
||||||
|
for column in columns:
|
||||||
|
column["cards"].sort(
|
||||||
|
key=lambda card: (
|
||||||
|
card["due_date"] is None,
|
||||||
|
card["due_date"] or date.max,
|
||||||
|
card["client_name"].lower(),
|
||||||
|
card["service_name"].lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"columns": columns,
|
||||||
|
"q": q,
|
||||||
|
"status": status_filter,
|
||||||
|
"today": today,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_employee_engagement_workflow(
|
||||||
|
db: Session,
|
||||||
|
scope: EmployeeScope,
|
||||||
|
engagement_id: int,
|
||||||
|
*,
|
||||||
|
actor_user_id: int,
|
||||||
|
financial_year: str | None = None,
|
||||||
|
) -> ClientServiceTaskInstance:
|
||||||
|
"""Start the first pending task assigned to the employee in an engagement.
|
||||||
|
|
||||||
|
Existing task status, lock, tenant, branch and financial-year controls are
|
||||||
|
reused. No engagement or task records are duplicated.
|
||||||
|
"""
|
||||||
|
stmt = _employee_work_task_query(
|
||||||
|
db,
|
||||||
|
scope,
|
||||||
|
assigned_only=True,
|
||||||
|
financial_year=financial_year,
|
||||||
|
).where(ClientServiceTaskInstance.subscription_id == engagement_id)
|
||||||
|
tasks = db.execute(
|
||||||
|
stmt.order_by(
|
||||||
|
ClientServiceTaskInstance.sequence_no.asc(),
|
||||||
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||||
|
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||||
|
ClientServiceTaskInstance.id.asc(),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if not tasks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Engagement work not found or not assigned to you",
|
||||||
|
)
|
||||||
|
|
||||||
|
already_started = next(
|
||||||
|
(
|
||||||
|
task
|
||||||
|
for task in tasks
|
||||||
|
if (task.status or "").strip().lower() == "in_progress"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if already_started:
|
||||||
|
return already_started
|
||||||
|
|
||||||
|
first_pending = next(
|
||||||
|
(
|
||||||
|
task
|
||||||
|
for task in tasks
|
||||||
|
if (task.status or "pending").strip().lower() == "pending"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not first_pending:
|
||||||
|
return tasks[0]
|
||||||
|
|
||||||
|
return update_own_service_task_status(
|
||||||
|
db,
|
||||||
|
scope,
|
||||||
|
first_pending.id,
|
||||||
|
status="in_progress",
|
||||||
|
remarks=None,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
financial_year=financial_year,
|
||||||
|
)
|
||||||
|
|
||||||
def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> list[EngagementDocument]:
|
def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> list[EngagementDocument]:
|
||||||
stmt = (
|
stmt = (
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">My Work</h2>
|
<h2 class="text-xl font-semibold text-slate-900">My Work</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Board view of your assigned engagements. Open a card to work on tasks and refer to engagement documents.</p>
|
<p class="mt-1 text-sm text-slate-500">One card per assigned engagement. Start, continue, follow up or view the full workflow from here.</p>
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-6">
|
<div class="grid gap-4 md:grid-cols-6">
|
||||||
<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">{{ work_payload.summary.total }}</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">Engagements</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.total }}</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">Open</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.open }}</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">Open</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.open }}</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">In Progress</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.in_progress }}</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">In Progress</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.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">{{ work_payload.summary.blocked }}</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">{{ work_payload.summary.blocked }}</div></div>
|
||||||
@@ -36,48 +34,85 @@
|
|||||||
<div class="grid gap-3 md:grid-cols-[1fr_220px_auto]">
|
<div class="grid gap-3 md:grid-cols-[1fr_220px_auto]">
|
||||||
<input type="search" name="q" value="{{ q or '' }}" placeholder="Search engagement, task, client or service" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
<input type="search" name="q" value="{{ q or '' }}" placeholder="Search engagement, task, client or service" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||||
<option value="open" {% if status == 'open' %}selected{% endif %}>Open work</option>
|
<option value="open" {% if status == 'open' %}selected{% endif %}>Open engagements</option>
|
||||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
|
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
|
||||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||||
<option value="blocked" {% if status == 'blocked' %}selected{% endif %}>Blocked</option>
|
<option value="blocked" {% if status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||||
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Completed</option>
|
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Completed</option>
|
||||||
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed work</option>
|
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed engagements</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply</button>
|
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-4">
|
<div class="space-y-5">
|
||||||
{% for column in work_payload.columns %}
|
{% for column in work_payload.columns %}
|
||||||
<section class="min-h-[420px] rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-soft">
|
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-4 shadow-soft">
|
||||||
<div class="mb-3 flex items-center justify-between px-1">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold text-slate-900">{{ column.label }}</h3>
|
<div>
|
||||||
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ column.cards|length }}</span>
|
<h3 class="text-base font-semibold text-slate-900">{{ column.label }}</h3>
|
||||||
|
{% if column.code == 'pending' %}<p class="mt-1 text-xs text-slate-500">Assigned engagements where work has not started.</p>{% endif %}
|
||||||
|
{% if column.code == 'in_progress' %}<p class="mt-1 text-xs text-slate-500">Started engagements with remaining assigned work.</p>{% endif %}
|
||||||
|
{% if column.code == 'blocked' %}<p class="mt-1 text-xs text-slate-500">Engagements waiting for documents, clarification, review or another dependency.</p>{% endif %}
|
||||||
|
{% if column.code == 'completed' %}<p class="mt-1 text-xs text-slate-500">All tasks assigned to you in these engagements are complete.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600">{{ column.cards|length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{% for card in column.cards %}
|
{% for card in column.cards %}
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow-soft">
|
<article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm hover:border-brand-200 hover:shadow-soft">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ card.client_code or 'Client' }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ card.client_code or 'Client' }}</div>
|
||||||
<h4 class="mt-1 text-sm font-semibold text-slate-900">{{ card.client_name }}</h4>
|
<h4 class="mt-1 text-base font-semibold text-slate-900">{{ card.client_name }}</h4>
|
||||||
</div>
|
</div>
|
||||||
{% if card.overdue_count %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue {{ card.overdue_count }}</span>{% elif card.due_today_count %}<span class="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-700">Due today</span>{% endif %}
|
{% if card.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% elif card.is_due_today %}<span class="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-700">Due today</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 rounded-xl bg-slate-50 p-3">
|
<div class="mt-3 rounded-xl bg-slate-50 p-3">
|
||||||
<div class="text-sm font-semibold text-slate-900">{{ card.service_name }}</div>
|
<div class="text-sm font-semibold text-slate-900">{{ card.service_name }}</div>
|
||||||
<div class="mt-1 text-xs text-slate-500">FY {{ card.financial_year }} · Due {{ card.due_date or '-' }}</div>
|
<div class="mt-1 text-xs text-slate-500">FY {{ card.financial_year }} · Due {{ card.due_date or '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
|
|
||||||
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.open_count }}</div><div class="text-slate-500">Open</div></div>
|
<div class="mt-4">
|
||||||
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.blocked_count }}</div><div class="text-slate-500">Blocked</div></div>
|
<div class="flex items-center justify-between text-xs">
|
||||||
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.completed_count }}</div><div class="text-slate-500">Done</div></div>
|
<span class="font-semibold text-slate-600">My progress</span>
|
||||||
|
<span class="font-semibold text-slate-900">{{ card.progress_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div class="h-full rounded-full bg-brand-600" style="width: {{ card.progress_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-slate-500">{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if column.code == 'blocked' %}
|
||||||
|
<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="mt-1 text-sm font-medium text-rose-900">{{ card.blocked_reason }}</div>
|
||||||
|
{% if card.blocked_task_name %}<div class="mt-1 text-xs text-rose-700">Task: {{ card.blocked_task_name }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif card.next_task_name and column.code != 'completed' %}
|
||||||
|
<div class="mt-4 text-xs text-slate-500">Next: <span class="font-medium text-slate-700">{{ card.next_task_name }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if card.latest_comment %}<p class="mt-3 truncate text-xs text-slate-500">Latest: {{ card.latest_comment.message }}</p>{% endif %}
|
{% if card.latest_comment %}<p class="mt-3 truncate text-xs text-slate-500">Latest: {{ card.latest_comment.message }}</p>{% endif %}
|
||||||
<a href="/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">Open Work Details</a>
|
|
||||||
|
{% if column.code == 'pending' %}
|
||||||
|
<form method="post" action="/employee/work/engagements/{{ card.engagement_id }}/start" class="mt-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="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">Start</button>
|
||||||
|
</form>
|
||||||
|
{% elif column.code == 'in_progress' %}
|
||||||
|
<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' %}
|
||||||
|
<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>
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No cards in this column.</div>
|
<div class="md:col-span-2 xl:col-span-3 rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No engagement cards in this section.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<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">My Work Board</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">Full 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">Full Documents</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from app.modules.employees.service import (
|
|||||||
list_employee_work_dashboard,
|
list_employee_work_dashboard,
|
||||||
list_employee_work_kanban,
|
list_employee_work_kanban,
|
||||||
get_employee_engagement_work_board,
|
get_employee_engagement_work_board,
|
||||||
|
start_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,
|
||||||
@@ -2047,6 +2048,48 @@ def employee_my_work(request: Request, q: str = "", status: str = "open"):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@portal_router.post("/work/engagements/{engagement_id}/start")
|
||||||
|
def employee_my_work_engagement_start(
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
start_employee_engagement_workflow(
|
||||||
|
db,
|
||||||
|
scope,
|
||||||
|
engagement_id,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
|
financial_year=_active_financial_year(request),
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/employee/work/engagements/{engagement_id}",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@portal_router.get("/work/engagements/{engagement_id}")
|
@portal_router.get("/work/engagements/{engagement_id}")
|
||||||
def employee_my_work_engagement_board(request: Request, engagement_id: int):
|
def employee_my_work_engagement_board(request: Request, engagement_id: int):
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
|
|||||||
Reference in New Issue
Block a user