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" %}
|
||||
|
||||
{% 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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user