Improve staff My Work board filters business display and pagination
This commit is contained in:
@@ -3112,16 +3112,23 @@ def list_employee_work_kanban(
|
||||
q: str = "",
|
||||
status: str = "open",
|
||||
financial_year: str | None = None,
|
||||
period: str = "",
|
||||
service: str = "",
|
||||
due_bucket: str = "",
|
||||
sort: str = "due_asc",
|
||||
page_size: int = 10,
|
||||
in_progress_page: int = 1,
|
||||
pending_page: int = 1,
|
||||
blocked_page: int = 1,
|
||||
completed_page: int = 1,
|
||||
completed_page_size: int = 25,
|
||||
) -> dict[str, Any]:
|
||||
"""Return exactly one employee-board card per assigned engagement.
|
||||
|
||||
The task engine remains unchanged. The board derives an engagement state from
|
||||
the staff member's assigned tasks using this precedence:
|
||||
This is still the existing task/engagement workflow. The board only adds
|
||||
presentation filters and independent pagination. Engagement state remains:
|
||||
|
||||
completed -> all assigned tasks are closed
|
||||
blocked -> at least one assigned task is blocked
|
||||
blocked -> at least one assigned task is blocked / workflow blocker exists
|
||||
in_progress -> at least one assigned task is started/completed
|
||||
pending -> no assigned task has started
|
||||
"""
|
||||
@@ -3131,30 +3138,36 @@ def list_employee_work_kanban(
|
||||
if status_filter not in valid_filters:
|
||||
status_filter = "open"
|
||||
|
||||
q_filter = (q or "").strip().lower()
|
||||
period_filter = (period or "").strip()
|
||||
service_filter = (service or "").strip()
|
||||
due_filter = (due_bucket or "").strip().lower()
|
||||
valid_due_filters = {"", "overdue", "today", "next_7", "next_15", "this_month", "no_due"}
|
||||
if due_filter not in valid_due_filters:
|
||||
due_filter = ""
|
||||
|
||||
sort_filter = (sort or "due_asc").strip().lower()
|
||||
valid_sorts = {"due_asc", "due_desc", "business_asc", "service_asc"}
|
||||
if sort_filter not in valid_sorts:
|
||||
sort_filter = "due_asc"
|
||||
|
||||
try:
|
||||
page_size = max(1, min(int(page_size or 10), 50))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 10
|
||||
if page_size not in {10, 25, 50}:
|
||||
page_size = 10
|
||||
|
||||
stmt = _employee_work_task_query(
|
||||
db,
|
||||
scope,
|
||||
assigned_only=True,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
if q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
ClientServiceTaskInstance.task_name.ilike(like),
|
||||
ClientServiceTaskInstance.description.ilike(like),
|
||||
ClientServiceTaskInstance.client.has(
|
||||
or_(Client.client_name.ilike(like), Client.trade_name.ilike(like), Client.client_code.ilike(like))
|
||||
),
|
||||
ClientServiceTaskInstance.catalogue.has(
|
||||
or_(
|
||||
ServiceCatalogue.service_name.ilike(like),
|
||||
ServiceCatalogue.service_code.ilike(like),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Search is deliberately applied after engagement/scope resolution below.
|
||||
# That retains task/client/service search and also allows registration,
|
||||
# business-unit, branch, trade-name and GSTIN searches.
|
||||
tasks = db.execute(
|
||||
stmt.order_by(
|
||||
ClientServiceTaskInstance.subscription_id.asc(),
|
||||
@@ -3208,16 +3221,29 @@ def list_employee_work_kanban(
|
||||
"context": "",
|
||||
"scope_label": "Client",
|
||||
"client_code": getattr(client, "client_code", None) or "",
|
||||
"client_name": getattr(client, "client_name", None) or "",
|
||||
"client_trade_name": getattr(client, "trade_name", None) or "",
|
||||
},
|
||||
)
|
||||
primary_name = (scope_display.get("primary") or "Unlinked Client").strip()
|
||||
legal_client_name = (scope_display.get("client_name") or getattr(client, "client_name", None) or "").strip()
|
||||
# Business/trade/scope name is primary. Show legal client name only
|
||||
# when it is genuinely different, ignoring case and whitespace.
|
||||
norm_primary = " ".join(primary_name.casefold().split())
|
||||
norm_client = " ".join(legal_client_name.casefold().split())
|
||||
client_context = (scope_display.get("context") or "").strip()
|
||||
if legal_client_name and norm_client == norm_primary:
|
||||
client_context = ""
|
||||
|
||||
engagement_lookup[engagement_id] = {
|
||||
"engagement_id": engagement_id,
|
||||
"subscription": subscription,
|
||||
"label": _subscription_label(subscription, task),
|
||||
"client_name": scope_display["primary"],
|
||||
"client_name": primary_name,
|
||||
"legal_client_name": legal_client_name if norm_client != norm_primary else "",
|
||||
"client_code": scope_display.get("client_code", "") or getattr(client, "client_code", None) or "",
|
||||
"scope_secondary": scope_display.get("secondary", ""),
|
||||
"scope_context": scope_display.get("context", ""),
|
||||
"scope_context": client_context,
|
||||
"scope_label": scope_display.get("scope_label", "Client"),
|
||||
"service_name": (
|
||||
getattr(getattr(subscription, "catalogue", None), "service_name", None)
|
||||
@@ -3229,6 +3255,11 @@ def list_employee_work_kanban(
|
||||
or getattr(task, "financial_year", None)
|
||||
or "-"
|
||||
),
|
||||
"period_label": (
|
||||
(getattr(subscription, "period_label", None) if subscription else None)
|
||||
or getattr(task, "period_label", None)
|
||||
or ""
|
||||
),
|
||||
"due_date": (
|
||||
getattr(subscription, "current_due_date", None)
|
||||
if subscription
|
||||
@@ -3260,11 +3291,7 @@ def list_employee_work_kanban(
|
||||
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 (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
|
||||
@@ -3282,6 +3309,10 @@ def list_employee_work_kanban(
|
||||
if not is_closed and not card["next_task_name"]:
|
||||
card["next_task_name"] = task.task_name
|
||||
|
||||
# Options are derived from the employee's assigned engagements in the active FY.
|
||||
period_options = sorted({str(card.get("period_label") or "").strip() for card in engagement_lookup.values() if str(card.get("period_label") or "").strip()})
|
||||
service_options = sorted({str(card.get("service_name") or "").strip() for card in engagement_lookup.values() if str(card.get("service_name") or "").strip()}, key=str.casefold)
|
||||
|
||||
summary = {
|
||||
"total": 0,
|
||||
"open": 0,
|
||||
@@ -3293,6 +3324,26 @@ def list_employee_work_kanban(
|
||||
"due_today": 0,
|
||||
}
|
||||
|
||||
def _matches_due(card: dict[str, Any]) -> bool:
|
||||
due_date = card.get("due_date")
|
||||
if not due_filter:
|
||||
return True
|
||||
if due_filter == "no_due":
|
||||
return due_date is None
|
||||
if due_date is None:
|
||||
return False
|
||||
if due_filter == "overdue":
|
||||
return due_date < today
|
||||
if due_filter == "today":
|
||||
return due_date == today
|
||||
if due_filter == "next_7":
|
||||
return today <= due_date <= (today + timedelta(days=7))
|
||||
if due_filter == "next_15":
|
||||
return today <= due_date <= (today + timedelta(days=15))
|
||||
if due_filter == "this_month":
|
||||
return due_date.year == today.year and due_date.month == today.month
|
||||
return True
|
||||
|
||||
for card in engagement_lookup.values():
|
||||
total = card["task_count"]
|
||||
completed = card["completed_count"]
|
||||
@@ -3330,14 +3381,43 @@ def list_employee_work_kanban(
|
||||
card["is_overdue"] = bool(card["overdue_count"])
|
||||
card["is_due_today"] = bool(card["due_today_count"])
|
||||
|
||||
include = (
|
||||
include_status = (
|
||||
status_filter == "open"
|
||||
or (status_filter == "closed" and card_status == "completed")
|
||||
or status_filter == card_status
|
||||
)
|
||||
if not include:
|
||||
if not include_status:
|
||||
continue
|
||||
|
||||
if period_filter and str(card.get("period_label") or "") != period_filter:
|
||||
continue
|
||||
if service_filter and str(card.get("service_name") or "") != service_filter:
|
||||
continue
|
||||
if not _matches_due(card):
|
||||
continue
|
||||
|
||||
if q_filter:
|
||||
task_search_text = " ".join(
|
||||
f"{getattr(task, 'task_name', '') or ''} {getattr(task, 'description', '') or ''}"
|
||||
for task in card.get("tasks", [])
|
||||
)
|
||||
haystack = " ".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
card.get("client_name"),
|
||||
card.get("legal_client_name"),
|
||||
card.get("client_code"),
|
||||
card.get("scope_secondary"),
|
||||
card.get("scope_context"),
|
||||
card.get("service_name"),
|
||||
card.get("period_label"),
|
||||
card.get("financial_year"),
|
||||
task_search_text,
|
||||
)
|
||||
).lower()
|
||||
if q_filter not in haystack:
|
||||
continue
|
||||
|
||||
column_lookup[card_status]["cards"].append(card)
|
||||
summary["total"] += 1
|
||||
if card_status == "completed":
|
||||
@@ -3350,47 +3430,61 @@ def list_employee_work_kanban(
|
||||
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(),
|
||||
def _sort_cards(cards: list[dict[str, Any]]) -> None:
|
||||
if sort_filter == "due_desc":
|
||||
cards.sort(
|
||||
key=lambda card: (
|
||||
card["due_date"] is not None,
|
||||
card["due_date"] or date.min,
|
||||
card["client_name"].casefold(),
|
||||
card["service_name"].casefold(),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
column["total_cards"] = len(column["cards"])
|
||||
elif sort_filter == "business_asc":
|
||||
cards.sort(key=lambda card: (card["client_name"].casefold(), card["due_date"] is None, card["due_date"] or date.max, card["service_name"].casefold()))
|
||||
elif sort_filter == "service_asc":
|
||||
cards.sort(key=lambda card: (card["service_name"].casefold(), card["due_date"] is None, card["due_date"] or date.max, card["client_name"].casefold()))
|
||||
else:
|
||||
# Existing behaviour: earliest due date first, undated work last.
|
||||
cards.sort(key=lambda card: (card["due_date"] is None, card["due_date"] or date.max, card["client_name"].casefold(), card["service_name"].casefold()))
|
||||
|
||||
try:
|
||||
completed_page = max(int(completed_page or 1), 1)
|
||||
except (TypeError, ValueError):
|
||||
completed_page = 1
|
||||
try:
|
||||
completed_page_size = max(1, min(int(completed_page_size or 25), 100))
|
||||
except (TypeError, ValueError):
|
||||
completed_page_size = 25
|
||||
|
||||
completed_column = column_lookup["completed"]
|
||||
completed_total = int(completed_column["total_cards"])
|
||||
completed_pages = max(1, (completed_total + completed_page_size - 1) // completed_page_size)
|
||||
if completed_page > completed_pages:
|
||||
completed_page = completed_pages
|
||||
completed_start = (completed_page - 1) * completed_page_size
|
||||
completed_end = completed_start + completed_page_size
|
||||
completed_column["cards"] = completed_column["cards"][completed_start:completed_end]
|
||||
|
||||
completed_pagination = {
|
||||
"page": completed_page,
|
||||
"page_size": completed_page_size,
|
||||
"total": completed_total,
|
||||
"pages": completed_pages,
|
||||
"has_previous": completed_page > 1,
|
||||
"has_next": completed_page < completed_pages,
|
||||
"previous_page": completed_page - 1 if completed_page > 1 else None,
|
||||
"next_page": completed_page + 1 if completed_page < completed_pages else None,
|
||||
"start": completed_start + 1 if completed_total else 0,
|
||||
"end": min(completed_end, completed_total),
|
||||
page_requests = {
|
||||
"in_progress": in_progress_page,
|
||||
"pending": pending_page,
|
||||
"blocked": blocked_page,
|
||||
"completed": completed_page,
|
||||
}
|
||||
paginations: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for column in columns:
|
||||
_sort_cards(column["cards"])
|
||||
total_cards = len(column["cards"])
|
||||
column["total_cards"] = total_cards
|
||||
try:
|
||||
page = max(int(page_requests.get(column["code"], 1) or 1), 1)
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
pages = max(1, (total_cards + page_size - 1) // page_size)
|
||||
if page > pages:
|
||||
page = pages
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
column["cards"] = column["cards"][start_index:end_index]
|
||||
pagination = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total_cards,
|
||||
"pages": pages,
|
||||
"has_previous": page > 1,
|
||||
"has_next": page < pages,
|
||||
"previous_page": page - 1 if page > 1 else None,
|
||||
"next_page": page + 1 if page < pages else None,
|
||||
"start": start_index + 1 if total_cards else 0,
|
||||
"end": min(end_index, total_cards),
|
||||
}
|
||||
column["pagination"] = pagination
|
||||
paginations[column["code"]] = pagination
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
@@ -3398,10 +3492,22 @@ def list_employee_work_kanban(
|
||||
"q": q,
|
||||
"status": status_filter,
|
||||
"today": today,
|
||||
"completed_pagination": completed_pagination,
|
||||
"filters": {
|
||||
"period": period_filter,
|
||||
"service": service_filter,
|
||||
"due": due_filter,
|
||||
"sort": sort_filter,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"filter_options": {
|
||||
"periods": period_options,
|
||||
"services": service_options,
|
||||
},
|
||||
"paginations": paginations,
|
||||
# Backward compatible for any template/consumer still using the old key.
|
||||
"completed_pagination": paginations["completed"],
|
||||
}
|
||||
|
||||
|
||||
def start_employee_engagement_workflow(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
|
||||
Reference in New Issue
Block a user