Improve partner review board filters business display due dates and pagination

This commit is contained in:
A R R R Associates
2026-08-21 13:42:19 +05:30
parent 92f06f333a
commit 208808da6a
2 changed files with 490 additions and 176 deletions
@@ -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 @@
<tr class="align-top hover:bg-slate-50/70">
<td class="px-4 py-4 text-sm">
<a href="/partner/reviews/engagements/{{ task.subscription_id }}" class="font-semibold text-slate-900 hover:text-brand-700">{{ task.client_display }}</a>
<a href="/partner/reviews/engagements/{{ task.subscription_id }}" class="font-semibold text-slate-900 hover:text-brand-700">{{ task.business_display }}</a>
{% if task.legal_client_display %}
<div class="mt-0.5 text-xs font-medium text-slate-600">{{ task.legal_client_display }}</div>
{% endif %}
{% if task.scope_context_display %}
<div class="mt-1 text-[11px] text-slate-400">{{ task.scope_context_display }}</div>
{% endif %}
<div class="mt-1 text-xs text-slate-500">{{ task.engagement_label }}</div>
<div class="mt-1 text-[11px] text-slate-400">Engagement #{{ task.subscription_id }}</div>
</td>
<td class="px-4 py-4 text-sm">
<div class="font-semibold text-slate-800">{{ task.period_label_display }}</div>
{% if task.financial_year_display %}
<div class="mt-1 text-xs text-slate-500">FY {{ task.financial_year_display }}</div>
{% endif %}
{% if task.engagement_start_date or task.engagement_end_date %}
<div class="mt-1 text-[11px] text-slate-400">
{% 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 %}
</div>
{% endif %}
{% if task.engagement_due_date %}
<div class="mt-2 text-xs font-semibold {% if task.due_status_code == 'overdue' %}text-red-700{% elif task.due_status_code in ['today','soon'] %}text-amber-700{% else %}text-slate-700{% endif %}">
Due {{ task.engagement_due_date }}
</div>
<div class="mt-0.5 text-[11px] {% if task.due_status_code == 'overdue' %}text-red-600{% elif task.due_status_code in ['today','soon'] %}text-amber-600{% else %}text-slate-400{% endif %}">{{ task.due_status_label }}</div>
{% else %}
<div class="mt-2 text-xs text-slate-400">No statutory due date</div>
{% endif %}
</td>
<td class="px-4 py-4 text-sm">
{% if column.code == 'pending_review' %}
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">Pending Review</span>
@@ -48,14 +74,13 @@
{% else %}
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Completed</span>
{% endif %}
{% if stats.overdue %}
<div class="mt-2"><span class="rounded-full bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue item</span></div>
{% endif %}
</td>
<td class="px-4 py-4 text-sm">
<div class="font-semibold text-slate-900">{{ stats.completed }} / {{ stats.total }}</div>
<div class="mt-1 text-xs text-slate-500">closed task{{ '' if stats.completed == 1 else 's' }} / visible task{{ '' if stats.total == 1 else 's' }}</div>
</td>
<td class="px-4 py-4 text-sm">
<div class="flex flex-wrap gap-1.5">
{% if stats.review %}<span class="rounded-full bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-700">{{ stats.review }} review</span>{% endif %}
@@ -64,26 +89,25 @@
{% if not stats.review and not stats.blocked and not stats.rework %}<span class="text-xs text-slate-500">No review exception</span>{% endif %}
</div>
</td>
<td class="px-4 py-4 text-sm text-slate-700">
{% for assignee in stats.assignees[:3] %}
<div>{{ assignee }}</div>
{% endfor %}
{% if stats.assignees|length > 3 %}
<div class="mt-1 text-xs text-slate-500">+{{ stats.assignees|length - 3 }} more</div>
{% endif %}
{% for assignee in stats.assignees[:3] %}<div>{{ assignee }}</div>{% endfor %}
{% if stats.assignees|length > 3 %}<div class="mt-1 text-xs text-slate-500">+{{ stats.assignees|length - 3 }} more</div>{% endif %}
{% if not stats.assignees %}<span class="text-slate-500">Unassigned</span>{% endif %}
</td>
<td class="px-4 py-4 text-sm">
<div class="text-xs font-semibold text-slate-700">{{ task.priority_label }}</div>
<div class="mt-1 text-xs text-slate-500">{% if task.internal_target_date %}Target {{ task.internal_target_date }}{% else %}No task target{% endif %}</div>
<div class="mt-1 text-xs text-slate-500">{% if task.internal_target_date %}Task target {{ task.internal_target_date }}{% else %}No task target{% endif %}</div>
</td>
<td class="px-4 py-4 text-right">
<a href="/partner/reviews/engagements/{{ task.subscription_id }}" class="inline-flex rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open Review</a>
</td>
</tr>
{% 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) %}
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-soft">
{% set total_items = ids|length %}
{% set total_pages = ((total_items + page_size - 1) // page_size) if total_items else 1 %}
@@ -96,20 +120,23 @@
<div>
<h3 class="text-base font-semibold text-slate-900">{{ title }}</h3>
<p class="mt-1 text-xs text-slate-500">{{ subtitle }}</p>
{% if total_items %}<p class="mt-1 text-[11px] text-slate-400">Showing {{ start_index + 1 }}-{{ [start_index + page_ids|length, total_items]|min }} of {{ total_items }}</p>{% endif %}
</div>
{% if tone == 'review' %}
<span class="w-fit rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">{{ ids|length }} engagement{{ '' if ids|length == 1 else 's' }}</span>
<span class="w-fit rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">{{ total_items }} engagement{{ '' if total_items == 1 else 's' }}</span>
{% elif tone == 'progress' %}
<span class="w-fit rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700">{{ ids|length }} engagement{{ '' if ids|length == 1 else 's' }}</span>
<span class="w-fit rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700">{{ total_items }} engagement{{ '' if total_items == 1 else 's' }}</span>
{% else %}
<span class="w-fit rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">{{ ids|length }} engagement{{ '' if ids|length == 1 else 's' }}</span>
<span class="w-fit rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">{{ total_items }} engagement{{ '' if total_items == 1 else 's' }}</span>
{% endif %}
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client / Engagement</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Business / Client / Engagement</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Period / Due</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task Progress</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Review Items</th>
@@ -128,34 +155,18 @@
{% endif %}
{% endfor %}
{% endfor %}
{% if total_items == 0 %}
<tr>
<td colspan="7" class="px-6 py-10 text-center text-sm text-slate-500">No engagements in this section.</td>
</tr>
{% endif %}
{% if total_items == 0 %}<tr><td colspan="8" class="px-6 py-10 text-center text-sm text-slate-500">No engagements in this section.</td></tr>{% endif %}
</tbody>
</table>
</div>
{% if total_items > page_size %}
<div class="flex flex-col gap-3 border-t border-slate-200 px-5 py-4 text-xs text-slate-600 sm:flex-row sm:items-center sm:justify-between">
<div>
Showing {{ start_index + 1 }}-{{ [start_index + page_ids|length, total_items]|min }} of {{ total_items }} engagements
</div>
<div>Page {{ page }} of {{ total_pages }}</div>
<div class="flex items-center gap-2">
{% if page > 1 %}
<a href="?q={{ q_value }}&{{ page_param }}={{ page - 1 }}"
class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 font-semibold text-slate-700 hover:bg-slate-50">Previous</a>
{% else %}
<span class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 font-semibold text-slate-400">Previous</span>
{% endif %}
<span class="font-semibold text-slate-700">Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?q={{ q_value }}&{{ page_param }}={{ page + 1 }}"
class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 font-semibold text-slate-700 hover:bg-slate-50">Next</a>
{% else %}
<span class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 font-semibold text-slate-400">Next</span>
{% endif %}
{% if page > 1 %}<a href="?{{ filter_query }}&{{ page_param }}={{ page - 1 }}" class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 font-semibold text-slate-700 hover:bg-slate-50">Previous</a>{% else %}<span class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 font-semibold text-slate-400">Previous</span>{% endif %}
<span class="font-semibold text-slate-700">{{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}<a href="?{{ filter_query }}&{{ page_param }}={{ page + 1 }}" class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 font-semibold text-slate-700 hover:bg-slate-50">Next</a>{% else %}<span class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 font-semibold text-slate-400">Next</span>{% endif %}
</div>
</div>
{% endif %}
@@ -163,139 +174,119 @@
{% endmacro %}
<div class="space-y-6">
<div class="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-soft lg:flex-row lg:items-center lg:justify-between">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-semibold">Partner Review Board</h2>
<p class="text-sm text-slate-500">Engagement-level review queue. Task-level review, rework and history remain available inside each engagement.</p>
<p class="text-sm text-slate-500">Engagement-level review queue with business identity, engagement period, statutory due date and independent pagination for each section.</p>
</div>
<div class="text-xs text-slate-500">Active FY context: <span class="font-semibold text-slate-700">{{ payload.active_financial_year or 'Not set' }}</span></div>
</div>
<form method="get" class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8">
<div class="xl:col-span-2">
<label class="mb-1 block text-xs font-semibold text-slate-600">Search</label>
<input name="q" value="{{ q or '' }}" placeholder="Business, client, task or service" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Financial Year</label>
<select name="fy" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="all" {% if payload.filters.financial_year == 'all' %}selected{% endif %}>All FYs</option>
{% for item in payload.filter_options.financial_years %}<option value="{{ item }}" {% if payload.filters.financial_year == item %}selected{% endif %}>FY {{ item }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Period</label>
<select name="period" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All periods</option>
{% for item in payload.filter_options.periods %}<option value="{{ item }}" {% if payload.filters.period == item %}selected{% endif %}>{{ item }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Service</label>
<select name="service_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All services</option>
{% for item in payload.filter_options.services %}<option value="{{ item.id }}" {% if payload.filters.service_id == item.id %}selected{% endif %}>{{ item.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Due Date</label>
<select name="due" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All due dates</option>
<option value="overdue" {% if payload.filters.due_bucket == 'overdue' %}selected{% endif %}>Overdue</option>
<option value="today" {% if payload.filters.due_bucket == 'today' %}selected{% endif %}>Due today</option>
<option value="next_7" {% if payload.filters.due_bucket == 'next_7' %}selected{% endif %}>Due in next 7 days</option>
<option value="next_15" {% if payload.filters.due_bucket == 'next_15' %}selected{% endif %}>Due in next 15 days</option>
<option value="this_month" {% if payload.filters.due_bucket == 'this_month' %}selected{% endif %}>Due this month</option>
<option value="none" {% if payload.filters.due_bucket == 'none' %}selected{% endif %}>No due date</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Assigned Team</label>
<select name="assignee_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All team members</option>
{% for item in payload.filter_options.teams %}<option value="{{ item.id }}" {% if payload.filters.assignee_id == item.id %}selected{% endif %}>{{ item.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Priority</label>
<select name="priority" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All priorities</option>
{% for code, label in task_priorities %}<option value="{{ code }}" {% if payload.filters.priority == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-600">Rows / section</label>
<select name="page_size" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for size in [10,25,50] %}<option value="{{ size }}" {% if page_size == size %}selected{% endif %}>{{ size }}</option>{% endfor %}
</select>
</div>
<div class="flex items-end gap-2 xl:col-span-2">
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Apply Filters</button>
<a href="/partner/reviews" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Clear</a>
</div>
<form method="get" class="flex gap-2">
<input name="q" value="{{ q or '' }}" placeholder="Search client, task or service" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Search</button>
</form>
</div>
{% 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 %}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Pending Review</div>
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.pending_review|length }}</div>
<div class="mt-1 text-xs text-slate-500">Engagements with completed work awaiting review</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Clarification Required</div>
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.clarification_required|length }}</div>
<div class="mt-1 text-xs text-slate-500">Engagements with blocked tasks</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Rework Sent</div>
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.rework_sent|length }}</div>
<div class="mt-1 text-xs text-slate-500">Engagements containing rework items</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">In Progress</div>
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.approved|length }}</div>
<div class="mt-1 text-xs text-slate-500">Engagements currently moving with the team</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed</div>
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.completed|length }}</div>
<div class="mt-1 text-xs text-slate-500">Engagements containing closed tasks</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-7">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Pending Review</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.pending_review|length }}</div><div class="mt-1 text-xs text-slate-500">Awaiting partner review</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Clarification</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.clarification_required|length }}</div><div class="mt-1 text-xs text-slate-500">Blocked for clarification</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Rework</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.rework_sent|length }}</div><div class="mt-1 text-xs text-slate-500">Rework outstanding</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">In Progress</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.approved|length }}</div><div class="mt-1 text-xs text-slate-500">Moving with team</div></div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Completed</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.completed|length }}</div><div class="mt-1 text-xs text-slate-500">Completed engagements</div></div>
<div class="rounded-2xl border border-red-200 bg-red-50 p-4"><div class="text-xs font-semibold uppercase tracking-wide text-red-600">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-800">{{ metrics.overdue|length }}</div><div class="mt-1 text-xs text-red-600">Past engagement due date</div></div>
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4"><div class="text-xs font-semibold uppercase tracking-wide text-amber-700">Due ≤ 7 Days</div><div class="mt-1 text-2xl font-semibold text-amber-900">{{ metrics.due_soon|length }}</div><div class="mt-1 text-xs text-amber-700">Due today / next 7 days</div></div>
</div>
{# 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 ''
) }}
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
Review remains task-level inside the engagement. This board only groups the existing task records into one engagement row for easier partner navigation.
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">Review remains task-level inside the engagement. This board only groups the existing task records into one engagement row for easier partner navigation.</div>
</div>
{% endblock %}
+344 -21
View File
@@ -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()