diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index 29f42bb..78651c5 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -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, diff --git a/app/modules/employees/templates/employees/self_work.html b/app/modules/employees/templates/employees/self_work.html index 3f7743a..947f138 100644 --- a/app/modules/employees/templates/employees/self_work.html +++ b/app/modules/employees/templates/employees/self_work.html @@ -31,9 +31,9 @@