From 208808da6a3699b6ff6f3ec6e0ca7095e012cd89 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Fri, 21 Aug 2026 13:42:19 +0530 Subject: [PATCH] Improve partner review board filters business display due dates and pagination --- .../templates/partners/review_board.html | 301 +++++++-------- app/modules/partners/ui.py | 365 +++++++++++++++++- 2 files changed, 490 insertions(+), 176 deletions(-) diff --git a/app/modules/partners/templates/partners/review_board.html b/app/modules/partners/templates/partners/review_board.html index 52fa253..59e149f 100644 --- a/app/modules/partners/templates/partners/review_board.html +++ b/app/modules/partners/templates/partners/review_board.html @@ -3,7 +3,7 @@ {% include "modules/partners/templates/partners/_partner_tabs.html" %} {% macro engagement_row(task, column, payload) %} - {% set stats = namespace(total=0, completed=0, blocked=0, review=0, rework=0, overdue=false, assignees=[]) %} + {% set stats = namespace(total=0, completed=0, blocked=0, review=0, rework=0, assignees=[]) %} {% for stat_column in payload.columns %} {% for item in stat_column.tasks %} {% if item.subscription_id == task.subscription_id %} @@ -20,9 +20,6 @@ {% if stat_column.code == 'rework_sent' %} {% set stats.rework = stats.rework + 1 %} {% endif %} - {% if item.is_overdue %} - {% set stats.overdue = true %} - {% endif %} {% if item.assignee_display and item.assignee_display not in stats.assignees %} {% set _ = stats.assignees.append(item.assignee_display) %} {% endif %} @@ -32,10 +29,39 @@ - {{ task.client_display }} + {{ task.business_display }} + {% if task.legal_client_display %} +
{{ task.legal_client_display }}
+ {% endif %} + {% if task.scope_context_display %} +
{{ task.scope_context_display }}
+ {% endif %}
{{ task.engagement_label }}
Engagement #{{ task.subscription_id }}
+ + +
{{ task.period_label_display }}
+ {% if task.financial_year_display %} +
FY {{ task.financial_year_display }}
+ {% endif %} + {% if task.engagement_start_date or task.engagement_end_date %} +
+ {% if task.engagement_start_date %}{{ task.engagement_start_date }}{% endif %} + {% if task.engagement_start_date and task.engagement_end_date %} → {% endif %} + {% if task.engagement_end_date %}{{ task.engagement_end_date }}{% endif %} +
+ {% endif %} + {% if task.engagement_due_date %} +
+ Due {{ task.engagement_due_date }} +
+
{{ task.due_status_label }}
+ {% else %} +
No statutory due date
+ {% endif %} + + {% if column.code == 'pending_review' %} Pending Review @@ -48,14 +74,13 @@ {% else %} Completed {% endif %} - {% if stats.overdue %} -
Overdue item
- {% endif %} +
{{ stats.completed }} / {{ stats.total }}
closed task{{ '' if stats.completed == 1 else 's' }} / visible task{{ '' if stats.total == 1 else 's' }}
+
{% if stats.review %}{{ stats.review }} review{% endif %} @@ -64,26 +89,25 @@ {% if not stats.review and not stats.blocked and not stats.rework %}No review exception{% endif %}
+ - {% for assignee in stats.assignees[:3] %} -
{{ assignee }}
- {% endfor %} - {% if stats.assignees|length > 3 %} -
+{{ stats.assignees|length - 3 }} more
- {% endif %} + {% for assignee in stats.assignees[:3] %}
{{ assignee }}
{% endfor %} + {% if stats.assignees|length > 3 %}
+{{ stats.assignees|length - 3 }} more
{% endif %} {% if not stats.assignees %}Unassigned{% endif %} +
{{ task.priority_label }}
-
{% if task.internal_target_date %}Target {{ task.internal_target_date }}{% else %}No task target{% endif %}
+
{% if task.internal_target_date %}Task target {{ task.internal_target_date }}{% else %}No task target{% endif %}
+ Open Review {% endmacro %} -{% macro panel(title, subtitle, ids, payload, tone, page_param, current_page, page_size, q_value) %} +{% macro panel(title, subtitle, ids, payload, tone, page_param, current_page, page_size, filter_query) %}
{% set total_items = ids|length %} {% set total_pages = ((total_items + page_size - 1) // page_size) if total_items else 1 %} @@ -96,20 +120,23 @@

{{ title }}

{{ subtitle }}

+ {% if total_items %}

Showing {{ start_index + 1 }}-{{ [start_index + page_ids|length, total_items]|min }} of {{ total_items }}

{% endif %}
{% if tone == 'review' %} - {{ ids|length }} engagement{{ '' if ids|length == 1 else 's' }} + {{ total_items }} engagement{{ '' if total_items == 1 else 's' }} {% elif tone == 'progress' %} - {{ ids|length }} engagement{{ '' if ids|length == 1 else 's' }} + {{ total_items }} engagement{{ '' if total_items == 1 else 's' }} {% else %} - {{ ids|length }} engagement{{ '' if ids|length == 1 else 's' }} + {{ total_items }} engagement{{ '' if total_items == 1 else 's' }} {% endif %} +
- + + @@ -128,34 +155,18 @@ {% endif %} {% endfor %} {% endfor %} - {% if total_items == 0 %} - - - - {% endif %} + {% if total_items == 0 %}{% endif %}
Client / EngagementBusiness / Client / EngagementPeriod / Due Status Task Progress Review Items
No engagements in this section.
No engagements in this section.
{% if total_items > page_size %}
-
- Showing {{ start_index + 1 }}-{{ [start_index + page_ids|length, total_items]|min }} of {{ total_items }} engagements -
+
Page {{ page }} of {{ total_pages }}
- {% if page > 1 %} - Previous - {% else %} - Previous - {% endif %} - Page {{ page }} of {{ total_pages }} - {% if page < total_pages %} - Next - {% else %} - Next - {% endif %} + {% if page > 1 %}Previous{% else %}Previous{% endif %} + {{ page }} / {{ total_pages }} + {% if page < total_pages %}Next{% else %}Next{% endif %}
{% endif %} @@ -163,139 +174,119 @@ {% endmacro %}
-
-
-

Partner Review Board

-

Engagement-level review queue. Task-level review, rework and history remain available inside each engagement.

+
+
+
+

Partner Review Board

+

Engagement-level review queue with business identity, engagement period, statutory due date and independent pagination for each section.

+
+
Active FY context: {{ payload.active_financial_year or 'Not set' }}
-
- - + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
- {% set metrics = namespace(pending_review=[], clarification_required=[], rework_sent=[], approved=[], completed=[]) %} + {% set metrics = namespace(pending_review=[], clarification_required=[], rework_sent=[], approved=[], completed=[], overdue=[], due_soon=[]) %} {% for column in payload.columns %} {% for task in column.tasks %} {% if task.subscription_id %} - {% if column.code == 'pending_review' and task.subscription_id not in metrics.pending_review %} - {% set _ = metrics.pending_review.append(task.subscription_id) %} - {% elif column.code == 'clarification_required' and task.subscription_id not in metrics.clarification_required %} - {% set _ = metrics.clarification_required.append(task.subscription_id) %} - {% elif column.code == 'rework_sent' and task.subscription_id not in metrics.rework_sent %} - {% set _ = metrics.rework_sent.append(task.subscription_id) %} - {% elif column.code == 'approved' and task.subscription_id not in metrics.approved %} - {% set _ = metrics.approved.append(task.subscription_id) %} - {% elif column.code == 'completed' and task.subscription_id not in metrics.completed %} - {% set _ = metrics.completed.append(task.subscription_id) %} - {% endif %} + {% if column.code == 'pending_review' and task.subscription_id not in metrics.pending_review %}{% set _ = metrics.pending_review.append(task.subscription_id) %} + {% elif column.code == 'clarification_required' and task.subscription_id not in metrics.clarification_required %}{% set _ = metrics.clarification_required.append(task.subscription_id) %} + {% elif column.code == 'rework_sent' and task.subscription_id not in metrics.rework_sent %}{% set _ = metrics.rework_sent.append(task.subscription_id) %} + {% elif column.code == 'approved' and task.subscription_id not in metrics.approved %}{% set _ = metrics.approved.append(task.subscription_id) %} + {% elif column.code == 'completed' and task.subscription_id not in metrics.completed %}{% set _ = metrics.completed.append(task.subscription_id) %}{% endif %} + {% if task.due_status_code == 'overdue' and task.subscription_id not in metrics.overdue %}{% set _ = metrics.overdue.append(task.subscription_id) %}{% endif %} + {% if task.due_status_code in ['today','soon'] and task.subscription_id not in metrics.due_soon %}{% set _ = metrics.due_soon.append(task.subscription_id) %}{% endif %} {% endif %} {% endfor %} {% endfor %} -
-
-
Pending Review
-
{{ metrics.pending_review|length }}
-
Engagements with completed work awaiting review
-
-
-
Clarification Required
-
{{ metrics.clarification_required|length }}
-
Engagements with blocked tasks
-
-
-
Rework Sent
-
{{ metrics.rework_sent|length }}
-
Engagements containing rework items
-
-
-
In Progress
-
{{ metrics.approved|length }}
-
Engagements currently moving with the team
-
-
-
Completed
-
{{ metrics.completed|length }}
-
Engagements containing closed tasks
-
+
+
Pending Review
{{ metrics.pending_review|length }}
Awaiting partner review
+
Clarification
{{ metrics.clarification_required|length }}
Blocked for clarification
+
Rework
{{ metrics.rework_sent|length }}
Rework outstanding
+
In Progress
{{ metrics.approved|length }}
Moving with team
+
Completed
{{ metrics.completed|length }}
Completed engagements
+
Overdue
{{ metrics.overdue|length }}
Past engagement due date
+
Due ≤ 7 Days
{{ metrics.due_soon|length }}
Due today / next 7 days
- {# Each engagement appears in exactly one panel: review first, then in progress, then completed. #} + {# Exactly three board sections. Existing task-level states are preserved inside Review Required. #} {% set groups = namespace(review=[], progress=[], completed=[]) %} - {% for column in payload.columns %} - {% if column.code in ['pending_review', 'clarification_required', 'rework_sent'] %} - {% for task in column.tasks %} - {% if task.subscription_id and task.subscription_id not in groups.review %} - {% set _ = groups.review.append(task.subscription_id) %} - {% endif %} - {% endfor %} - {% endif %} - {% endfor %} - - {% for column in payload.columns %} - {% if column.code == 'approved' %} - {% for task in column.tasks %} - {% if task.subscription_id and task.subscription_id not in groups.review and task.subscription_id not in groups.progress %} - {% set _ = groups.progress.append(task.subscription_id) %} - {% endif %} - {% endfor %} - {% endif %} - {% endfor %} - - {% for column in payload.columns %} - {% if column.code == 'completed' %} - {% for task in column.tasks %} - {% if task.subscription_id and task.subscription_id not in groups.review and task.subscription_id not in groups.progress and task.subscription_id not in groups.completed %} - {% set _ = groups.completed.append(task.subscription_id) %} - {% endif %} - {% endfor %} - {% endif %} - {% endfor %} + {% for column in payload.columns %}{% if column.code in ['pending_review', 'clarification_required', 'rework_sent'] %}{% for task in column.tasks %}{% if task.subscription_id and task.subscription_id not in groups.review %}{% set _ = groups.review.append(task.subscription_id) %}{% endif %}{% endfor %}{% endif %}{% endfor %} + {% for column in payload.columns %}{% if column.code == 'approved' %}{% for task in column.tasks %}{% if task.subscription_id and task.subscription_id not in groups.review and task.subscription_id not in groups.progress %}{% set _ = groups.progress.append(task.subscription_id) %}{% endif %}{% endfor %}{% endif %}{% endfor %} + {% for column in payload.columns %}{% if column.code == 'completed' %}{% for task in column.tasks %}{% if task.subscription_id and task.subscription_id not in groups.review and task.subscription_id not in groups.progress and task.subscription_id not in groups.completed %}{% set _ = groups.completed.append(task.subscription_id) %}{% endif %}{% endfor %}{% endif %}{% endfor %} {% set review_page = request.query_params.get('review_page', '1')|int %} {% set progress_page = request.query_params.get('progress_page', '1')|int %} {% set completed_page = request.query_params.get('completed_page', '1')|int %} - {% set panel_page_size = 25 %} - {{ panel( - 'Review Required', - 'Pending review, clarification-required and rework engagements are shown first.', - groups.review, - payload, - 'review', - 'review_page', - review_page, - panel_page_size, - q or '' - ) }} + {{ panel('Review Required', 'Pending review, clarification-required and rework engagements are shown first.', groups.review, payload, 'review', 'review_page', review_page, page_size, filter_query) }} + {{ panel('In Progress', 'Engagements currently moving with the team and not presently waiting in the review queue.', groups.progress, payload, 'progress', 'progress_page', progress_page, page_size, filter_query) }} + {{ panel('Completed', 'Completed engagement rows remain available separately for review history and reference.', groups.completed, payload, 'completed', 'completed_page', completed_page, page_size, filter_query) }} - {{ panel( - 'In Progress', - 'Engagements currently moving with the team and not presently waiting in the review queue.', - groups.progress, - payload, - 'progress', - 'progress_page', - progress_page, - panel_page_size, - q or '' - ) }} - - {{ panel( - 'Completed', - 'Completed engagement rows remain available separately for review history and reference.', - groups.completed, - payload, - 'completed', - 'completed_page', - completed_page, - panel_page_size, - q or '' - ) }} - -
- Review remains task-level inside the engagement. This board only groups the existing task records into one engagement row for easier partner navigation. -
+
Review remains task-level inside the engagement. This board only groups the existing task records into one engagement row for easier partner navigation.
{% endblock %} diff --git a/app/modules/partners/ui.py b/app/modules/partners/ui.py index 1404e34..50e9242 100644 --- a/app/modules/partners/ui.py +++ b/app/modules/partners/ui.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import defaultdict from datetime import date, datetime, timezone from typing import Any +from urllib.parse import urlencode from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse @@ -15,7 +16,8 @@ from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.documents.models import EngagementDocument -from app.modules.clients.models import Client +from app.modules.clients.models import Client, ClientBranch, ClientBusinessUnit +from app.modules.registrations.models import ClientRegistration, RegistrationType from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES from app.modules.services.models import ( ClientServiceSubscription, @@ -189,17 +191,237 @@ def _task_bucket(task: ClientServiceTaskInstance, current_user) -> str: return "approved" -def build_partner_payload(db: Session, request: Request, current_user, *, q: str = "") -> dict[str, Any]: +def _normalise_name(value: str | None) -> str: + return " ".join((value or "").split()).casefold() + + +def _partner_scope_display_map( + db: Session, + *, + tenant_id: int, + subscriptions: list[ClientServiceSubscription], +) -> dict[int, dict[str, str]]: + """Resolve business/trade identity for the Partner Review Board. + + Business/trade name is always primary. The legal client name is shown only + when it is genuinely different, preserving the engagement's existing scope. + """ + subscriptions = [row for row in subscriptions if row is not None] + if not subscriptions: + return {} + + business_ids = {int(row.business_unit_id) for row in subscriptions if getattr(row, "business_unit_id", None)} + branch_ids = {int(row.client_branch_id) for row in subscriptions if getattr(row, "client_branch_id", None)} + registration_ids = {int(row.registration_id) for row in subscriptions if getattr(row, "registration_id", None)} + + businesses: dict[int, ClientBusinessUnit] = {} + if business_ids: + businesses = { + row.id: row for row in db.execute( + select(ClientBusinessUnit).where( + ClientBusinessUnit.tenant_id == tenant_id, + ClientBusinessUnit.id.in_(business_ids), + ) + ).scalars().all() + } + + branches: dict[int, ClientBranch] = {} + if branch_ids: + branches = { + row.id: row for row in db.execute( + select(ClientBranch).where( + ClientBranch.tenant_id == tenant_id, + ClientBranch.id.in_(branch_ids), + ) + ).scalars().all() + } + + registrations: dict[int, ClientRegistration] = {} + registration_types: dict[int, RegistrationType] = {} + if registration_ids: + for registration, registration_type in db.execute( + select(ClientRegistration, RegistrationType) + .join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id) + .where( + ClientRegistration.tenant_id == tenant_id, + ClientRegistration.id.in_(registration_ids), + ) + ).all(): + registrations[registration.id] = registration + registration_types[registration.id] = registration_type + + extra_business_ids = { + int(row.business_unit_id) for row in registrations.values() + if row.business_unit_id and row.business_unit_id not in businesses + } + if extra_business_ids: + businesses.update({ + row.id: row for row in db.execute( + select(ClientBusinessUnit).where( + ClientBusinessUnit.tenant_id == tenant_id, + ClientBusinessUnit.id.in_(extra_business_ids), + ) + ).scalars().all() + }) + + extra_branch_ids = { + int(row.client_branch_id) for row in registrations.values() + if row.client_branch_id and row.client_branch_id not in branches + } + if extra_branch_ids: + branches.update({ + row.id: row for row in db.execute( + select(ClientBranch).where( + ClientBranch.tenant_id == tenant_id, + ClientBranch.id.in_(extra_branch_ids), + ) + ).scalars().all() + }) + + result: dict[int, dict[str, str]] = {} + for subscription in subscriptions: + client = getattr(subscription, "client", None) + client_name = (getattr(client, "client_name", None) or "").strip() + client_trade_name = (getattr(client, "trade_name", None) or "").strip() + primary = client_trade_name or client_name or "Unlinked Client" + secondary_client = "" + scope_context = "" + scope_type = (getattr(subscription, "scope_type", None) or "client").strip().lower() + + if scope_type == "registration": + registration = registrations.get(getattr(subscription, "registration_id", None)) + registration_type = registration_types.get(getattr(subscription, "registration_id", None)) + if registration: + business = businesses.get(getattr(registration, "business_unit_id", None) or getattr(subscription, "business_unit_id", None)) + branch = branches.get(getattr(registration, "client_branch_id", None) or getattr(subscription, "client_branch_id", None)) + business_trade = ((getattr(business, "trade_name", None) or "").strip() if business else "") + business_name = ((getattr(business, "business_name", None) or "").strip() if business else "") + branch_name = ((getattr(branch, "branch_name", None) or "").strip() if branch else "") + primary = ( + (getattr(registration, "trade_name", None) or "").strip() + or business_trade or business_name or branch_name + or (getattr(registration, "legal_name", None) or "").strip() + or client_trade_name or client_name or "Unlinked Client" + ) + reg_code = ((getattr(registration_type, "code", None) or "").strip() if registration_type else "") + reg_no = (getattr(registration, "registration_number", None) or "").strip() + scope_context = " · ".join(part for part in [reg_code, reg_no] if part) + + elif scope_type == "business_unit": + business = businesses.get(getattr(subscription, "business_unit_id", None)) + if business: + primary = ((getattr(business, "trade_name", None) or "").strip() + or (getattr(business, "business_name", None) or "").strip() + or primary) + scope_context = (getattr(business, "business_code", None) or "").strip() + + elif scope_type == "client_branch": + branch = branches.get(getattr(subscription, "client_branch_id", None)) + business = businesses.get(getattr(branch, "business_unit_id", None) if branch else getattr(subscription, "business_unit_id", None)) + business_display = "" + if business: + business_display = ((getattr(business, "trade_name", None) or "").strip() + or (getattr(business, "business_name", None) or "").strip()) + if branch: + primary = (getattr(branch, "branch_name", None) or "").strip() or business_display or primary + scope_context = (getattr(branch, "branch_code", None) or "").strip() + elif business_display: + primary = business_display + + if client_name and _normalise_name(client_name) != _normalise_name(primary): + secondary_client = client_name + + result[int(subscription.id)] = { + "primary": primary, + "client_name": client_name, + "secondary_client": secondary_client, + "scope_context": scope_context, + } + return result + + +def _due_bucket_matches(due: date | None, bucket: str, today: date) -> bool: + bucket = (bucket or "").strip().lower() + if not bucket or bucket == "all": + return True + if bucket == "none": + return due is None + if due is None: + return False + delta = (due - today).days + if bucket == "overdue": + return delta < 0 + if bucket == "today": + return delta == 0 + if bucket == "next_7": + return 0 <= delta <= 7 + if bucket == "next_15": + return 0 <= delta <= 15 + if bucket == "this_month": + return due.year == today.year and due.month == today.month + return True + + +def _due_status(due: date | None, today: date) -> tuple[str, str]: + if due is None: + return "none", "No due date" + delta = (due - today).days + if delta < 0: + days = abs(delta) + return "overdue", f"Overdue by {days} day{'s' if days != 1 else ''}" + if delta == 0: + return "today", "Due today" + if delta <= 7: + return "soon", f"Due in {delta} day{'s' if delta != 1 else ''}" + return "future", f"Due in {delta} days" + + +def build_partner_payload( + db: Session, + request: Request, + current_user, + *, + q: str = "", + financial_year_filter: str | None = None, + period: str = "", + service_id: int | None = None, + due_bucket: str = "", + assignee_id: int | None = None, + priority: str = "", +) -> dict[str, Any]: roles = set(get_user_roles(db, current_user.id)) tenant_id, branch_id = _active_tenant_branch(request, current_user, roles) - financial_year = _active_financial_year(request) + active_financial_year = _active_financial_year(request) + selected_fy = (financial_year_filter or "").strip() + if not selected_fy: + selected_fy = active_financial_year or "" + financial_year = None if selected_fy.lower() == "all" else (selected_fy or None) today = date.today() + # Filter choices are drawn from the same partner/tenant/branch scope, without + # narrowing by FY so the board can move between years without changing the + # global application FY context. + option_stmt = select(ClientServiceSubscription).options( + selectinload(ClientServiceSubscription.client), + selectinload(ClientServiceSubscription.catalogue), + ) + option_stmt = _subscription_scope_filter(option_stmt, tenant_id, branch_id, current_user, roles, financial_year=None) + option_engagements = db.execute(option_stmt).scalars().all() + financial_year_options = sorted({(row.financial_year or "").strip() for row in option_engagements if (row.financial_year or "").strip()}, reverse=True) + period_options = sorted({(row.period_label or "").strip() for row in option_engagements if (row.period_label or "").strip()}) + service_map = {} + for row in option_engagements: + catalogue = getattr(row, "catalogue", None) + if catalogue: + service_map[int(catalogue.id)] = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or f"Service #{catalogue.id}" + service_options = [{"id": key, "name": service_map[key]} for key in sorted(service_map, key=lambda x: str(service_map[x]).casefold())] + task_stmt = ( select(ClientServiceTaskInstance) .options( selectinload(ClientServiceTaskInstance.client), selectinload(ClientServiceTaskInstance.catalogue), + selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.client), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), @@ -209,16 +431,15 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str ) ) task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year) - if q.strip(): - like = f"%{q.strip()}%" - task_stmt = task_stmt.where( - or_( - ClientServiceTaskInstance.task_name.ilike(like), - ClientServiceTaskInstance.description.ilike(like), - ClientServiceTaskInstance.client.has(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))), - ) - ) + if period.strip(): + task_stmt = task_stmt.where(ClientServiceTaskInstance.subscription.has(ClientServiceSubscription.period_label == period.strip())) + if service_id: + task_stmt = task_stmt.where(ClientServiceTaskInstance.service_catalogue_id == int(service_id)) + if assignee_id: + task_stmt = task_stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assignee_id)) + if priority.strip(): + task_stmt = task_stmt.where(ClientServiceTaskInstance.priority == priority.strip()) + tasks = db.execute( task_stmt.order_by( ClientServiceTaskInstance.internal_target_date.is_(None), @@ -228,6 +449,57 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str ) ).scalars().all() + subscriptions_by_id = { + int(task.subscription_id): task.subscription + for task in tasks + if getattr(task, "subscription_id", None) and getattr(task, "subscription", None) + } + scope_display_map = _partner_scope_display_map(db, tenant_id=tenant_id, subscriptions=list(subscriptions_by_id.values())) + + # Team choices are limited to people actually present in the current scoped + # result set before search/due-date filtering. + team_map: dict[int, str] = {} + for task in tasks: + assignee = getattr(task, "assigned_to", None) + if assignee and getattr(assignee, "id", None): + team_map[int(assignee.id)] = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or f"User #{assignee.id}" + team_options = [{"id": key, "name": team_map[key]} for key in sorted(team_map, key=lambda x: str(team_map[x]).casefold())] + + filtered_tasks: list[ClientServiceTaskInstance] = [] + q_norm = _normalise_name(q) + for task in tasks: + _decorate_task(task, today) + subscription = getattr(task, "subscription", None) + display = scope_display_map.get(int(task.subscription_id), {}) if getattr(task, "subscription_id", None) else {} + task.business_display = display.get("primary") or task.client_display + task.legal_client_display = display.get("secondary_client") or "" + task.scope_context_display = display.get("scope_context") or "" + task.period_label_display = (getattr(subscription, "period_label", None) or "").strip() or "Annual / Full year" + task.financial_year_display = (getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "").strip() + task.engagement_due_date = getattr(subscription, "current_due_date", None) + task.engagement_original_due_date = getattr(subscription, "original_due_date", None) + task.engagement_start_date = getattr(subscription, "start_date", None) + task.engagement_end_date = getattr(subscription, "end_date", None) + task.due_status_code, task.due_status_label = _due_status(task.engagement_due_date, today) + + if not _due_bucket_matches(task.engagement_due_date, due_bucket, today): + continue + if q_norm: + searchable = " ".join([ + task.business_display or "", + task.legal_client_display or "", + task.client_display or "", + getattr(task, "task_name", None) or "", + getattr(task, "description", None) or "", + task.engagement_label or "", + task.period_label_display or "", + task.scope_context_display or "", + ]) + if q_norm not in _normalise_name(searchable): + continue + filtered_tasks.append(task) + tasks = filtered_tasks + columns = [ {"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []}, {"code": "clarification_required", "label": "Clarification Required", "hint": "Blocked tasks needing partner attention", "tasks": []}, @@ -239,7 +511,6 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()} for task in tasks: - _decorate_task(task, today) status = (task.status or "pending").lower() if getattr(task, "client_id", None): summary["clients"].add(task.client_id) @@ -250,9 +521,9 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str summary["pending_review"] += 1 if status == "blocked": summary["blocked"] += 1 - if getattr(task, "is_overdue", False): + if task.due_status_code == "overdue": summary["overdue"] += 1 - if getattr(task, "is_due_today", False): + if task.due_status_code == "today": summary["due_today"] += 1 if status in CLOSED_TASK_STATUSES: summary["completed"] += 1 @@ -293,8 +564,29 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str due = getattr(engagement, "current_due_date", None) engagement.is_overdue = bool(due and due < today and (engagement.status or "").lower() not in {"completed", "closed", "locked"}) - return {"summary": summary, "columns": columns, "engagements": engagements, "q": q, "today": today, "financial_year": financial_year} - + return { + "summary": summary, + "columns": columns, + "engagements": engagements, + "q": q, + "today": today, + "financial_year": financial_year, + "active_financial_year": active_financial_year, + "filters": { + "financial_year": selected_fy or "all", + "period": period or "", + "service_id": int(service_id) if service_id else None, + "due_bucket": due_bucket or "", + "assignee_id": int(assignee_id) if assignee_id else None, + "priority": priority or "", + }, + "filter_options": { + "financial_years": financial_year_options, + "periods": period_options, + "services": service_options, + "teams": team_options, + }, + } def _get_partner_task_or_redirect(db: Session, request: Request, current_user, task_id: int) -> ClientServiceTaskInstance | None: roles = set(get_user_roles(db, current_user.id)) @@ -321,7 +613,17 @@ def partner_dashboard(request: Request, q: str = ""): @router.get("/reviews") -def partner_review_board(request: Request, q: str = ""): +def partner_review_board( + request: Request, + q: str = "", + fy: str = "", + period: str = "", + service_id: str = "", + due: str = "", + assignee_id: str = "", + priority: str = "", + page_size: int = 10, +): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -329,8 +631,29 @@ def partner_review_board(request: Request, q: str = ""): return _redirect_login() if not _is_partner_user(db, current_user): return _redirect_denied() - payload = build_partner_payload(db, request, current_user, q=q) - return _render(request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[]) + page_size = page_size if page_size in {10, 25, 50} else 10 + service_id_value = int(service_id) if str(service_id).strip().isdigit() else None + assignee_id_value = int(assignee_id) if str(assignee_id).strip().isdigit() else None + payload = build_partner_payload( + db, request, current_user, q=q, financial_year_filter=fy, period=period, + service_id=service_id_value, due_bucket=due, assignee_id=assignee_id_value, priority=priority, + ) + preserved = { + "q": q.strip(), + "fy": payload["filters"]["financial_year"], + "period": period.strip(), + "service_id": service_id_value or "", + "due": due.strip(), + "assignee_id": assignee_id_value or "", + "priority": priority.strip(), + "page_size": page_size, + } + filter_query = urlencode({k: v for k, v in preserved.items() if v not in (None, "")}) + return _render( + request, "modules/partners/templates/partners/review_board.html", db, current_user, + title="Partner Review Board", payload=payload, q=q, page_size=page_size, + filter_query=filter_query, errors=[] + ) finally: db.close()