Improve partner review board filters business display due dates and pagination
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||||
|
|
||||||
{% macro engagement_row(task, column, payload) %}
|
{% 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 stat_column in payload.columns %}
|
||||||
{% for item in stat_column.tasks %}
|
{% for item in stat_column.tasks %}
|
||||||
{% if item.subscription_id == task.subscription_id %}
|
{% if item.subscription_id == task.subscription_id %}
|
||||||
@@ -20,9 +20,6 @@
|
|||||||
{% if stat_column.code == 'rework_sent' %}
|
{% if stat_column.code == 'rework_sent' %}
|
||||||
{% set stats.rework = stats.rework + 1 %}
|
{% set stats.rework = stats.rework + 1 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.is_overdue %}
|
|
||||||
{% set stats.overdue = true %}
|
|
||||||
{% endif %}
|
|
||||||
{% if item.assignee_display and item.assignee_display not in stats.assignees %}
|
{% if item.assignee_display and item.assignee_display not in stats.assignees %}
|
||||||
{% set _ = stats.assignees.append(item.assignee_display) %}
|
{% set _ = stats.assignees.append(item.assignee_display) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -32,10 +29,39 @@
|
|||||||
|
|
||||||
<tr class="align-top hover:bg-slate-50/70">
|
<tr class="align-top hover:bg-slate-50/70">
|
||||||
<td class="px-4 py-4 text-sm">
|
<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-xs text-slate-500">{{ task.engagement_label }}</div>
|
||||||
<div class="mt-1 text-[11px] text-slate-400">Engagement #{{ task.subscription_id }}</div>
|
<div class="mt-1 text-[11px] text-slate-400">Engagement #{{ task.subscription_id }}</div>
|
||||||
</td>
|
</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">
|
<td class="px-4 py-4 text-sm">
|
||||||
{% if column.code == 'pending_review' %}
|
{% 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>
|
<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 %}
|
{% else %}
|
||||||
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Completed</span>
|
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Completed</span>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
<td class="px-4 py-4 text-sm">
|
<td class="px-4 py-4 text-sm">
|
||||||
<div class="font-semibold text-slate-900">{{ stats.completed }} / {{ stats.total }}</div>
|
<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>
|
<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>
|
||||||
|
|
||||||
<td class="px-4 py-4 text-sm">
|
<td class="px-4 py-4 text-sm">
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<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 %}
|
{% 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 %}
|
{% 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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-4 py-4 text-sm text-slate-700">
|
<td class="px-4 py-4 text-sm text-slate-700">
|
||||||
{% for assignee in stats.assignees[:3] %}
|
{% for assignee in stats.assignees[:3] %}<div>{{ assignee }}</div>{% endfor %}
|
||||||
<div>{{ assignee }}</div>
|
{% if stats.assignees|length > 3 %}<div class="mt-1 text-xs text-slate-500">+{{ stats.assignees|length - 3 }} more</div>{% endif %}
|
||||||
{% 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 %}
|
{% if not stats.assignees %}<span class="text-slate-500">Unassigned</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-4 py-4 text-sm">
|
<td class="px-4 py-4 text-sm">
|
||||||
<div class="text-xs font-semibold text-slate-700">{{ task.priority_label }}</div>
|
<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>
|
||||||
|
|
||||||
<td class="px-4 py-4 text-right">
|
<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>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endmacro %}
|
{% 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">
|
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||||
{% set total_items = ids|length %}
|
{% set total_items = ids|length %}
|
||||||
{% set total_pages = ((total_items + page_size - 1) // page_size) if total_items else 1 %}
|
{% set total_pages = ((total_items + page_size - 1) // page_size) if total_items else 1 %}
|
||||||
@@ -96,20 +120,23 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-slate-900">{{ title }}</h3>
|
<h3 class="text-base font-semibold text-slate-900">{{ title }}</h3>
|
||||||
<p class="mt-1 text-xs text-slate-500">{{ subtitle }}</p>
|
<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>
|
</div>
|
||||||
{% if tone == 'review' %}
|
{% 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' %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-slate-200">
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
<thead class="bg-slate-50">
|
<thead class="bg-slate-50">
|
||||||
<tr>
|
<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">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">Task Progress</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Review Items</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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if total_items == 0 %}
|
{% 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 %}
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="px-6 py-10 text-center text-sm text-slate-500">No engagements in this section.</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if total_items > page_size %}
|
{% 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 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>
|
<div>Page {{ page }} of {{ total_pages }}</div>
|
||||||
Showing {{ start_index + 1 }}-{{ [start_index + page_ids|length, total_items]|min }} of {{ total_items }} engagements
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page > 1 %}
|
{% 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 %}
|
||||||
<a href="?q={{ q_value }}&{{ page_param }}={{ page - 1 }}"
|
<span class="font-semibold text-slate-700">{{ page }} / {{ total_pages }}</span>
|
||||||
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>
|
{% 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 %}
|
||||||
{% 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 %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -163,139 +174,119 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="space-y-6">
|
<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>
|
<div>
|
||||||
<h2 class="text-xl font-semibold">Partner Review Board</h2>
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</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 column in payload.columns %}
|
||||||
{% for task in column.tasks %}
|
{% for task in column.tasks %}
|
||||||
{% if task.subscription_id %}
|
{% if task.subscription_id %}
|
||||||
{% if column.code == 'pending_review' and task.subscription_id not in metrics.pending_review %}
|
{% if column.code == 'pending_review' and task.subscription_id not in metrics.pending_review %}{% set _ = metrics.pending_review.append(task.subscription_id) %}
|
||||||
{% 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 == 'clarification_required' and task.subscription_id not in metrics.clarification_required %}
|
{% elif column.code == 'rework_sent' and task.subscription_id not in metrics.rework_sent %}{% set _ = metrics.rework_sent.append(task.subscription_id) %}
|
||||||
{% set _ = metrics.clarification_required.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 == 'rework_sent' and task.subscription_id not in metrics.rework_sent %}
|
{% elif column.code == 'completed' and task.subscription_id not in metrics.completed %}{% set _ = metrics.completed.append(task.subscription_id) %}{% endif %}
|
||||||
{% set _ = metrics.rework_sent.append(task.subscription_id) %}
|
{% if task.due_status_code == 'overdue' and task.subscription_id not in metrics.overdue %}{% set _ = metrics.overdue.append(task.subscription_id) %}{% endif %}
|
||||||
{% elif column.code == 'approved' and task.subscription_id not in metrics.approved %}
|
{% 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 %}
|
||||||
{% 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 %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
<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="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="text-xs font-semibold uppercase tracking-wide text-slate-500">Pending Review</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="mt-1 text-2xl font-semibold text-slate-900">{{ metrics.pending_review|length }}</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="mt-1 text-xs text-slate-500">Engagements with completed work awaiting review</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>
|
<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-slate-200 bg-white p-4 shadow-soft">
|
<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="text-xs font-semibold uppercase tracking-wide text-slate-500">Clarification Required</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 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>
|
</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=[]) %}
|
{% set groups = namespace(review=[], progress=[], completed=[]) %}
|
||||||
{% for column in payload.columns %}
|
{% 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 %}
|
||||||
{% if column.code in ['pending_review', 'clarification_required', 'rework_sent'] %}
|
{% 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 task in column.tasks %}
|
{% 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 %}
|
||||||
{% 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 review_page = request.query_params.get('review_page', '1')|int %}
|
||||||
{% set progress_page = request.query_params.get('progress_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 completed_page = request.query_params.get('completed_page', '1')|int %}
|
||||||
{% set panel_page_size = 25 %}
|
|
||||||
|
|
||||||
{{ panel(
|
{{ 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) }}
|
||||||
'Review Required',
|
{{ 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) }}
|
||||||
'Pending review, clarification-required and rework engagements are shown first.',
|
{{ 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) }}
|
||||||
groups.review,
|
|
||||||
payload,
|
|
||||||
'review',
|
|
||||||
'review_page',
|
|
||||||
review_page,
|
|
||||||
panel_page_size,
|
|
||||||
q or ''
|
|
||||||
) }}
|
|
||||||
|
|
||||||
{{ panel(
|
<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>
|
||||||
'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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+344
-21
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import RedirectResponse
|
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.core.templating import templates
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.documents.models import EngagementDocument
|
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.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES
|
||||||
from app.modules.services.models import (
|
from app.modules.services.models import (
|
||||||
ClientServiceSubscription,
|
ClientServiceSubscription,
|
||||||
@@ -189,17 +191,237 @@ def _task_bucket(task: ClientServiceTaskInstance, current_user) -> str:
|
|||||||
return "approved"
|
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))
|
roles = set(get_user_roles(db, current_user.id))
|
||||||
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
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()
|
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 = (
|
task_stmt = (
|
||||||
select(ClientServiceTaskInstance)
|
select(ClientServiceTaskInstance)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ClientServiceTaskInstance.client),
|
selectinload(ClientServiceTaskInstance.client),
|
||||||
selectinload(ClientServiceTaskInstance.catalogue),
|
selectinload(ClientServiceTaskInstance.catalogue),
|
||||||
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.client),
|
||||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
|
||||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
|
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)
|
task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
||||||
if q.strip():
|
if period.strip():
|
||||||
like = f"%{q.strip()}%"
|
task_stmt = task_stmt.where(ClientServiceTaskInstance.subscription.has(ClientServiceSubscription.period_label == period.strip()))
|
||||||
task_stmt = task_stmt.where(
|
if service_id:
|
||||||
or_(
|
task_stmt = task_stmt.where(ClientServiceTaskInstance.service_catalogue_id == int(service_id))
|
||||||
ClientServiceTaskInstance.task_name.ilike(like),
|
if assignee_id:
|
||||||
ClientServiceTaskInstance.description.ilike(like),
|
task_stmt = task_stmt.where(ClientServiceTaskInstance.assigned_to_user_id == int(assignee_id))
|
||||||
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
|
if priority.strip():
|
||||||
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
|
task_stmt = task_stmt.where(ClientServiceTaskInstance.priority == priority.strip())
|
||||||
)
|
|
||||||
)
|
|
||||||
tasks = db.execute(
|
tasks = db.execute(
|
||||||
task_stmt.order_by(
|
task_stmt.order_by(
|
||||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||||
@@ -228,6 +449,57 @@ def build_partner_payload(db: Session, request: Request, current_user, *, q: str
|
|||||||
)
|
)
|
||||||
).scalars().all()
|
).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 = [
|
columns = [
|
||||||
{"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []},
|
{"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": []},
|
{"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()}
|
summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()}
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
_decorate_task(task, today)
|
|
||||||
status = (task.status or "pending").lower()
|
status = (task.status or "pending").lower()
|
||||||
if getattr(task, "client_id", None):
|
if getattr(task, "client_id", None):
|
||||||
summary["clients"].add(task.client_id)
|
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
|
summary["pending_review"] += 1
|
||||||
if status == "blocked":
|
if status == "blocked":
|
||||||
summary["blocked"] += 1
|
summary["blocked"] += 1
|
||||||
if getattr(task, "is_overdue", False):
|
if task.due_status_code == "overdue":
|
||||||
summary["overdue"] += 1
|
summary["overdue"] += 1
|
||||||
if getattr(task, "is_due_today", False):
|
if task.due_status_code == "today":
|
||||||
summary["due_today"] += 1
|
summary["due_today"] += 1
|
||||||
if status in CLOSED_TASK_STATUSES:
|
if status in CLOSED_TASK_STATUSES:
|
||||||
summary["completed"] += 1
|
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)
|
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"})
|
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:
|
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))
|
roles = set(get_user_roles(db, current_user.id))
|
||||||
@@ -321,7 +613,17 @@ def partner_dashboard(request: Request, q: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/reviews")
|
@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()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
current_user = get_current_user(request, db=db)
|
current_user = get_current_user(request, db=db)
|
||||||
@@ -329,8 +631,29 @@ def partner_review_board(request: Request, q: str = ""):
|
|||||||
return _redirect_login()
|
return _redirect_login()
|
||||||
if not _is_partner_user(db, current_user):
|
if not _is_partner_user(db, current_user):
|
||||||
return _redirect_denied()
|
return _redirect_denied()
|
||||||
payload = build_partner_payload(db, request, current_user, q=q)
|
page_size = page_size if page_size in {10, 25, 50} else 10
|
||||||
return _render(request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[])
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user