Add consultant dashboard ajax tabs v1

This commit is contained in:
A R R R Associates
2026-07-06 11:36:12 +05:30
parent dcb352d6c3
commit 4a2af8f9d1
9 changed files with 465 additions and 120 deletions
@@ -1,24 +1,14 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
<div class="space-y-6">
{% if not payload.workspace %}
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Consultant Portal</p>
<h2 class="mt-2 text-2xl font-semibold">Set up your consultant workspace</h2>
<p class="mt-2 max-w-3xl text-sm text-brand-100">Create your workspace to manage clients, communicate with firms and refer work.</p>
</section>
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
Your consultant workspace is not configured yet. Open profile/workspace settings and complete setup.
<div class="mt-4"><a href="/consultant/workspace/edit" class="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Configure Workspace</a></div>
</div>
{% else %}
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Consultant Portal</p>
<h2 class="mt-2 text-2xl font-semibold">Managed clients, firm referrals and consultant assignments</h2>
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track bookkeeping clients, clarifications, due dates, service requests and conversion requests with audit firms.</p>
<h2 class="mt-2 text-2xl font-semibold">Consultant Workspace</h2>
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track assigned work, linked clients, documents, clarifications and service requests from one place.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/consultant/work" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open Work Board</a>
@@ -27,102 +17,60 @@
</div>
</section>
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<a href="/consultant/communications" class="af-metric-card border-purple-200 bg-purple-50 hover:border-purple-300"><div class="text-xs font-semibold uppercase text-purple-700">Clarifications</div><div class="mt-2 text-3xl font-semibold text-purple-700">{{ payload.stats.pending_clarifications or 0 }}</div><div class="mt-1 text-xs text-purple-700">Firm messages</div></a>
<a href="/consultant/service-requests" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Service Requests</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.open_service_requests or 0 }}</div><div class="mt-1 text-xs text-slate-500">Open requests</div></a>
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Managed Clients</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.managed_clients or 0 }}</div><div class="mt-1 text-xs text-slate-500">Bookkeeping / support</div></a>
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Prospects</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.prospects or 0 }}</div><div class="mt-1 text-xs text-slate-500">Lead pipeline</div></a>
<a href="/consultant/work" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Due Soon</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ payload.stats.upcoming_due or 0 }}</div><div class="mt-1 text-xs text-amber-700">Next 30 days</div></a>
<a href="/consultant/work" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.stats.overdue or 0 }}</div><div class="mt-1 text-xs text-red-700">Linked firm work</div></a>
</section>
<section class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<aside class="space-y-6">
<div class="af-card">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="font-semibold text-slate-900">Workspace & Plan</h3>
<p class="mt-1 text-sm text-slate-600">{{ payload.workspace.workspace_name }}</p>
<p class="text-xs text-slate-500">{{ payload.workspace.workspace_type.replace('_',' ').title() }} • {{ payload.workspace.plan_code.replace('_',' ').title() }} • {{ payload.workspace.subscription_status.replace('_',' ').title() }}</p>
</div>
<a href="/consultant/workspace/edit" class="rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Edit</a>
</div>
<div class="mt-5">
<div class="flex items-center justify-between text-xs text-slate-500">
<span>Managed client usage</span>
<span>{{ payload.workspace_summary.managed_clients_used or 0 }} / {{ payload.workspace_summary.managed_clients_limit or 'No limit' }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-slate-900" style="width: {{ 100 if (payload.workspace_summary.usage_percent or 0) > 100 else (payload.workspace_summary.usage_percent or 0) }}%"></div></div>
<div class="mt-2 text-xs text-slate-500">{% if payload.workspace_summary.managed_clients_remaining is none %}No client limit configured.{% else %}{{ payload.workspace_summary.managed_clients_remaining }} client slots remaining.{% endif %}</div>
</div>
<div class="mt-5 grid gap-2 text-xs text-slate-600">
<div class="flex justify-between"><span>Client portal</span><span>{{ 'Enabled' if payload.workspace.allow_client_portal else 'Disabled' }}</span></div>
<div class="flex justify-between"><span>Firm referrals</span><span>{{ 'Enabled' if payload.workspace.allow_firm_referrals else 'Disabled' }}</span></div>
<div class="flex justify-between"><span>Marketplace</span><span>{{ 'Enabled' if payload.workspace.allow_service_marketplace else 'Disabled' }}</span></div>
</div>
</div>
<div class="af-card">
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
<div class="mt-4 grid gap-2 text-sm">
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Manage Clients</a>
{% if payload.workspace_summary.managed_clients_remaining is none or payload.workspace_summary.managed_clients_remaining > 0 %}
<a href="/consultant/managed-clients/new" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Add Managed Client</a>
{% endif %}
<a href="/consultant/communications" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Reply Clarifications</a>
<a href="/consultant/service-requests" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Service Requests / Leads</a>
</div>
</div>
</aside>
<div class="space-y-6">
<div class="af-card">
<div class="flex items-center justify-between gap-3">
<div><h3 class="text-lg font-semibold text-slate-900">Pending Consultant Clarifications</h3><p class="mt-1 text-sm text-slate-500">Firm messages visible to consultant. Internal and client-only notes are hidden.</p></div>
<a href="/consultant/communications" class="text-sm font-semibold text-brand-700 hover:underline">View all</a>
</div>
<div class="mt-4 space-y-3">
{% for comment, task, subscription, client, catalogue in payload.recent_firm_messages %}
<a href="/consultant/communications/{{ comment.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><div class="font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</div><div class="text-xs text-slate-500">Task: {{ task.task_name }} • {{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '' }}</div></div>
<span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span>
</div>
<div class="mt-3 line-clamp-3 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ comment.message }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No pending consultant-visible firm messages.</div>
<section class="rounded-3xl border border-slate-200 bg-white p-3 shadow-soft">
{% set tabs = [
('overview','Overview'),
('assigned-work','Assigned Work'),
('clients','Clients'),
('documents','Documents'),
('clarifications','Clarifications'),
('requests','Requests'),
('reports','Reports')
] %}
<div class="flex gap-2 overflow-x-auto" id="consultant-portal-tabs">
{% for code, label in tabs %}
<button type="button" data-tab="{{ code }}" class="consultant-portal-tab whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-semibold transition {% if active_tab == code %}bg-brand-600 text-white{% else %}text-slate-600 hover:bg-slate-100{% endif %}">{{ label }}</button>
{% endfor %}
</div>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<div class="af-card">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Pending Conversions</h3><a href="/consultant/managed-clients" class="text-xs font-semibold text-brand-700 hover:underline">View</a></div>
<div class="mt-4 space-y-3">{% for client in payload.pending_conversions %}<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.conversion_status.replace('_',' ').title() }}</div></a>{% else %}<div class="text-sm text-slate-500">No pending firm-client conversion requests.</div>{% endfor %}</div>
</div>
<div class="af-card">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Upcoming Due Dates</h3><span class="text-xs text-slate-500">30 days</span></div>
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.due_items %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No upcoming due dates.</div>{% endfor %}</div>
</div>
<div class="af-card border-red-200">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Overdue Work</h3><span class="text-xs text-red-500">Attention</span></div>
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.overdue_items %}<div class="rounded-xl border border-red-100 bg-red-50 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-red-600">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No overdue linked firm engagements.</div>{% endfor %}</div>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<div class="af-card">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">My Managed Clients</h3><a href="/consultant/managed-clients" class="text-xs font-semibold text-brand-700 hover:underline">View all</a></div>
<div class="mt-4 space-y-3">{% for client in payload.managed_clients %}<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.relationship_stage.replace('_',' ').title() }} • {{ client.status.replace('_',' ').title() }}</div></a>{% else %}<div class="text-sm text-slate-500">No managed clients yet.</div>{% endfor %}</div>
</div>
<div class="af-card">
<h3 class="font-semibold text-slate-900">Linked Firm Clients</h3><p class="mt-1 text-xs text-slate-500">Only explicitly linked clients are visible here.</p>
<div class="mt-4 space-y-3">{% for link in payload.active_links %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}</div><div class="mt-2 text-xs text-slate-600">Due dates {{ 'enabled' if link.can_view_due_dates else 'hidden' }} • Communications {{ 'enabled' if link.can_view_communications else 'hidden' }}</div></div>{% else %}<div class="text-sm text-slate-500">No firm clients are linked yet.</div>{% endfor %}</div>
</div>
</div>
</div>
</section>
{% endif %}
<section id="consultant-portal-panel">
{% include "modules/consultants/templates/consultants/portal_partials/overview.html" %}
</section>
</div>
<script>
(function(){
const buttons = Array.from(document.querySelectorAll('.consultant-portal-tab'));
const panel = document.getElementById('consultant-portal-panel');
if (!buttons.length || !panel) return;
function activate(tab) {
buttons.forEach(btn => {
if (btn.dataset.tab === tab) {
btn.classList.add('bg-brand-600','text-white');
btn.classList.remove('text-slate-600','hover:bg-slate-100');
} else {
btn.classList.remove('bg-brand-600','text-white');
btn.classList.add('text-slate-600','hover:bg-slate-100');
}
});
}
async function loadTab(tab) {
activate(tab);
panel.innerHTML = '<div class="rounded-3xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">Loading...</div>';
try {
const res = await fetch('/consultant/dashboard/tab/' + encodeURIComponent(tab), {headers: {'X-Requested-With':'fetch'}});
if (!res.ok) throw new Error('HTTP ' + res.status);
panel.innerHTML = await res.text();
history.replaceState(null, '', '/consultant/dashboard?tab=' + encodeURIComponent(tab));
} catch (err) {
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-8 text-center text-sm text-red-700 shadow-soft">Unable to load tab. Please refresh the page.</div>';
}
}
buttons.forEach(btn => btn.addEventListener('click', () => loadTab(btn.dataset.tab)));
})();
</script>
{% endblock %}
@@ -0,0 +1,43 @@
<div class="space-y-6">
<div class="af-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-900">Assigned Work</h3>
<p class="mt-1 text-sm text-slate-500">Consultant-visible assignments and firm task communications.</p>
</div>
<a href="/consultant/work" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Open Full Work Board</a>
</div>
</div>
{% set work_board = payload.work_board if payload.work_board is defined else {} %}
{% if work_board.columns %}
<section class="grid gap-4 xl:grid-cols-3">
{% for key, column in work_board.columns.items() %}
<div class="rounded-3xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="flex items-center justify-between">
<h4 class="font-semibold text-slate-900">{{ column.label }}</h4>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">{{ column.items|length }}</span>
</div>
<div class="mt-4 space-y-3">
{% for item in column.items[:5] %}
{% set task = item.task %}{% set client = item.client %}{% set catalogue = item.catalogue %}{% set comment = item.comment %}
<a href="/consultant/assignments/{{ task.id }}" class="block rounded-2xl border {% if item.is_overdue %}border-red-200 bg-red-50{% else %}border-slate-200 hover:bg-slate-50{% endif %} p-3">
<div class="font-semibold text-slate-900">{{ client.client_name }}</div>
<div class="mt-1 text-xs text-slate-500">{{ catalogue.service_name }} • {{ task.task_name }}</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-white px-2 py-1 font-semibold text-slate-600">{{ task.status.replace('_',' ').title() if task.status else 'Pending' }}</span>
{% if item.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 font-semibold text-red-700">Overdue</span>{% endif %}
</div>
<div class="mt-2 line-clamp-2 text-xs text-slate-600">{{ comment.message }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">No items in this column.</div>
{% endfor %}
</div>
</div>
{% endfor %}
</section>
{% else %}
<div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No consultant-visible assigned work found.</div>
{% endif %}
</div>
@@ -0,0 +1,39 @@
<div class="space-y-6">
<div class="af-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Clarifications</h3><p class="mt-1 text-sm text-slate-500">Firm messages visible to consultant and consultant replies.</p></div>
<a href="/consultant/communications" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Open Messages</a>
</div>
</div>
<section class="grid gap-6 xl:grid-cols-2">
<div class="af-card">
<h4 class="font-semibold text-slate-900">Pending From Firm</h4>
<div class="mt-4 space-y-3">
{% for comment, task, subscription, client, catalogue in payload.recent_firm_messages %}
<a href="/consultant/communications/{{ comment.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</div>
<div class="text-xs text-slate-500">{{ task.task_name }} • {{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '' }}</div>
<div class="mt-2 line-clamp-2 text-xs text-slate-600">{{ comment.message }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No pending firm messages.</div>
{% endfor %}
</div>
</div>
<div class="af-card">
<h4 class="font-semibold text-slate-900">My Recent Replies</h4>
<div class="mt-4 space-y-3">
{% for comment, task, subscription, client, catalogue in payload.recent_consultant_replies %}
<a href="/consultant/communications/{{ comment.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</div>
<div class="text-xs text-slate-500">{{ task.task_name }} • {{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '' }}</div>
<div class="mt-2 line-clamp-2 text-xs text-slate-600">{{ comment.message }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No consultant replies yet.</div>
{% endfor %}
</div>
</div>
</section>
</div>
@@ -0,0 +1,42 @@
<div class="grid gap-6 xl:grid-cols-2">
<div class="af-card">
<div class="flex items-center justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Managed Clients</h3><p class="mt-1 text-sm text-slate-500">Clients maintained by the consultant workspace.</p></div>
<a href="/consultant/managed-clients" class="text-sm font-semibold text-brand-700 hover:underline">View all</a>
</div>
<div class="mt-4 space-y-3">
{% for client in payload.managed_clients %}
<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ client.client_name }}</div>
<div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.relationship_stage.replace('_',' ').title() }} • {{ client.status.replace('_',' ').title() }}</div>
<div class="mt-2 text-xs text-slate-600">Conversion: {{ client.conversion_status.replace('_',' ').title() if client.conversion_status else 'Not Requested' }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No managed clients yet.</div>
{% endfor %}
</div>
</div>
<div class="af-card">
<div class="flex items-center justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Linked Firm Clients</h3><p class="mt-1 text-sm text-slate-500">Only explicitly linked firm clients are visible.</p></div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{{ payload.active_links|length }} active</span>
</div>
<div class="mt-4 space-y-3">
{% for link in payload.active_links %}
<div class="rounded-xl border border-slate-200 p-3">
<div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div>
<div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Client {{ 'visible' if link.can_view_client else 'hidden' }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Services {{ 'visible' if link.can_view_services else 'hidden' }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Due dates {{ 'visible' if link.can_view_due_dates else 'hidden' }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-600">Communications {{ 'visible' if link.can_view_communications else 'hidden' }}</span>
</div>
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No firm clients are linked yet.</div>
{% endfor %}
</div>
</div>
</div>
@@ -0,0 +1,38 @@
<div class="space-y-6">
<div class="af-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Documents</h3><p class="mt-1 text-sm text-slate-500">Shared firm documents and engagement documents visible to the consultant.</p></div>
<a href="/consultant/documents" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Open Document Centre</a>
</div>
</div>
{% set docs = payload.document_centre if payload.document_centre is defined else {} %}
<section class="grid gap-6 xl:grid-cols-2">
<div class="af-card">
<h4 class="font-semibold text-slate-900">Engagement Documents</h4>
<div class="mt-4 space-y-3">
{% for doc in docs.engagement_documents[:8] if docs.engagement_documents is defined %}
<div class="rounded-xl border border-slate-200 p-3">
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
<div class="text-xs text-slate-500">{{ doc.document_code or '-' }} • {{ doc.document_type or '-' }} • {{ doc.client.client_name if doc.client else '-' }}</div>
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No engagement documents available.</div>
{% endfor %}
</div>
</div>
<div class="af-card">
<h4 class="font-semibold text-slate-900">Permanent Documents</h4>
<div class="mt-4 space-y-3">
{% for doc in docs.permanent_documents[:8] if docs.permanent_documents is defined %}
<div class="rounded-xl border border-slate-200 p-3">
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
<div class="text-xs text-slate-500">{{ doc.document_code or '-' }} • {{ doc.category or '-' }} • {{ doc.client.client_name if doc.client else '-' }}</div>
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No permanent documents available.</div>
{% endfor %}
</div>
</div>
</section>
</div>
@@ -0,0 +1,93 @@
<div class="space-y-6">
{% if not consultant or not payload.workspace %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
Your consultant workspace is not configured yet. Open profile/workspace settings and complete setup.
<div class="mt-4"><a href="/consultant/workspace/edit" class="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Configure Workspace</a></div>
</div>
{% else %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<a href="/consultant/communications" class="af-metric-card border-purple-200 bg-purple-50 hover:border-purple-300"><div class="text-xs font-semibold uppercase text-purple-700">Clarifications</div><div class="mt-2 text-3xl font-semibold text-purple-700">{{ payload.stats.pending_clarifications or 0 }}</div><div class="mt-1 text-xs text-purple-700">Firm messages</div></a>
<a href="/consultant/service-requests" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Open Requests</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.open_service_requests or 0 }}</div><div class="mt-1 text-xs text-slate-500">Service requests</div></a>
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Managed Clients</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.managed_clients or 0 }}</div><div class="mt-1 text-xs text-slate-500">Bookkeeping / support</div></a>
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Linked Firm Clients</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.linked_clients or 0 }}</div><div class="mt-1 text-xs text-slate-500">Visible clients</div></a>
<a href="/consultant/work" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Due Soon</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ payload.stats.upcoming_due or 0 }}</div><div class="mt-1 text-xs text-amber-700">Next 30 days</div></a>
<a href="/consultant/work" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.stats.overdue or 0 }}</div><div class="mt-1 text-xs text-red-700">Attention</div></a>
</section>
<section class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<aside class="space-y-6">
<div class="af-card">
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="font-semibold text-slate-900">Workspace & Plan</h3>
<p class="mt-1 text-sm text-slate-600">{{ payload.workspace.workspace_name }}</p>
<p class="text-xs text-slate-500">{{ payload.workspace.workspace_type.replace('_',' ').title() }} • {{ payload.workspace.plan_code.replace('_',' ').title() }} • {{ payload.workspace.subscription_status.replace('_',' ').title() }}</p>
</div>
<a href="/consultant/workspace/edit" class="rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Edit</a>
</div>
<div class="mt-5">
<div class="flex items-center justify-between text-xs text-slate-500">
<span>Managed client usage</span>
<span>{{ payload.workspace_summary.managed_clients_used or 0 }} / {{ payload.workspace_summary.managed_clients_limit or 'No limit' }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-slate-900" style="width: {{ 100 if (payload.workspace_summary.usage_percent or 0) > 100 else (payload.workspace_summary.usage_percent or 0) }}%"></div></div>
<div class="mt-2 text-xs text-slate-500">{% if payload.workspace_summary.managed_clients_remaining is none %}No client limit configured.{% else %}{{ payload.workspace_summary.managed_clients_remaining }} client slots remaining.{% endif %}</div>
</div>
{% if payload.workspace_alerts %}
<div class="mt-4 space-y-2">
{% for alert in payload.workspace_alerts %}<div class="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-800">{{ alert }}</div>{% endfor %}
</div>
{% endif %}
</div>
<div class="af-card">
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
<div class="mt-4 grid gap-2 text-sm">
<a href="/consultant/work" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Open Work Board</a>
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Manage Clients</a>
{% if payload.workspace_summary.managed_clients_remaining is none or payload.workspace_summary.managed_clients_remaining > 0 %}<a href="/consultant/managed-clients/new" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Add Managed Client</a>{% endif %}
<a href="/consultant/communications" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Reply Clarifications</a>
<a href="/consultant/service-requests/new" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Raise Service Request</a>
</div>
</div>
</aside>
<div class="space-y-6">
<div class="af-card">
<div class="flex items-center justify-between gap-3">
<div><h3 class="text-lg font-semibold text-slate-900">Pending Consultant Clarifications</h3><p class="mt-1 text-sm text-slate-500">Firm messages visible to consultant. Internal and client-only notes are hidden.</p></div>
<a href="/consultant/communications" class="text-sm font-semibold text-brand-700 hover:underline">View all</a>
</div>
<div class="mt-4 space-y-3">
{% for comment, task, subscription, client, catalogue in payload.recent_firm_messages %}
<a href="/consultant/communications/{{ comment.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><div class="font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</div><div class="text-xs text-slate-500">Task: {{ task.task_name }} • {{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '' }}</div></div>
<span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span>
</div>
<div class="mt-3 line-clamp-3 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ comment.message }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No pending consultant-visible firm messages.</div>
{% endfor %}
</div>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<div class="af-card">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Upcoming Due Dates</h3><a href="/consultant/work" class="text-xs font-semibold text-brand-700 hover:underline">View</a></div>
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.due_items %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No upcoming due dates.</div>{% endfor %}</div>
</div>
<div class="af-card border-red-200">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Overdue Work</h3><a href="/consultant/work" class="text-xs font-semibold text-red-600 hover:underline">View</a></div>
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.overdue_items %}<div class="rounded-xl border border-red-100 bg-red-50 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-red-600">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No overdue linked firm engagements.</div>{% endfor %}</div>
</div>
<div class="af-card">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Open Requests</h3><a href="/consultant/service-requests" class="text-xs font-semibold text-brand-700 hover:underline">View</a></div>
<div class="mt-4 space-y-3">{% for row in payload.open_service_requests %}<a href="/consultant/service-requests/{{ row.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ row.title }}</div><div class="text-xs text-slate-500">{{ row.status.replace('_',' ').title() }} • {{ row.priority.replace('_',' ').title() if row.priority else 'Normal' }}</div></a>{% else %}<div class="text-sm text-slate-500">No open service requests.</div>{% endfor %}</div>
</div>
</div>
</div>
</section>
{% endif %}
</div>
@@ -0,0 +1,14 @@
<div class="space-y-6">
<div class="af-card">
<h3 class="text-lg font-semibold text-slate-900">Consultant Reports</h3>
<p class="mt-1 text-sm text-slate-500">Quick access to consultant self-service reports. These use existing consultant portal pages and data visibility rules.</p>
</div>
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<a href="/consultant/work" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300"><div class="text-sm font-semibold text-slate-900">Assigned Work Report</div><p class="mt-2 text-sm text-slate-500">View consultant-visible work board and assignments.</p></a>
<a href="/consultant/communications" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300"><div class="text-sm font-semibold text-slate-900">Clarification Pending Report</div><p class="mt-2 text-sm text-slate-500">Firm messages and consultant replies.</p></a>
<a href="/consultant/documents" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300"><div class="text-sm font-semibold text-slate-900">Document Centre Report</div><p class="mt-2 text-sm text-slate-500">Shared engagement and permanent documents.</p></a>
<a href="/consultant/managed-clients" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300"><div class="text-sm font-semibold text-slate-900">Managed Client Report</div><p class="mt-2 text-sm text-slate-500">Consultant client book and conversion status.</p></a>
<a href="/consultant/service-requests" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300"><div class="text-sm font-semibold text-slate-900">Service Request Report</div><p class="mt-2 text-sm text-slate-500">Requests raised to the firm.</p></a>
<a href="/consultant/workspace" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft hover:border-brand-300"><div class="text-sm font-semibold text-slate-900">Workspace Usage Report</div><p class="mt-2 text-sm text-slate-500">Workspace plan, usage and limits.</p></a>
</section>
</div>
@@ -0,0 +1,38 @@
<div class="space-y-6">
<div class="af-card">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div><h3 class="text-lg font-semibold text-slate-900">Service Requests</h3><p class="mt-1 text-sm text-slate-500">Requests and leads raised by consultant to the firm.</p></div>
<div class="flex flex-wrap gap-2"><a href="/consultant/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View All</a><a href="/consultant/service-requests/new" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">New Request</a></div>
</div>
</div>
<section class="grid gap-6 xl:grid-cols-2">
<div class="af-card">
<h4 class="font-semibold text-slate-900">Pending Requests</h4>
<div class="mt-4 space-y-3">
{% for row in payload.pending_service_requests %}
<a href="/consultant/service-requests/{{ row.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ row.title }}</div>
<div class="text-xs text-slate-500">{{ row.status.replace('_',' ').title() }} • {{ row.priority.replace('_',' ').title() if row.priority else 'Normal' }}</div>
<div class="mt-2 text-xs text-slate-600">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else 'General request') }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No pending service requests.</div>
{% endfor %}
</div>
</div>
<div class="af-card">
<h4 class="font-semibold text-slate-900">Recent Requests</h4>
<div class="mt-4 space-y-3">
{% for row in payload.service_requests %}
<a href="/consultant/service-requests/{{ row.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50">
<div class="font-semibold text-slate-900">{{ row.title }}</div>
<div class="text-xs text-slate-500">{{ row.status.replace('_',' ').title() }} • {{ row.created_at_utc.strftime('%d-%m-%Y') if row.created_at_utc else '' }}</div>
</a>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No service requests created.</div>
{% endfor %}
</div>
</div>
</section>
</div>
+102 -12
View File
@@ -1330,8 +1330,79 @@ async def consultant_profile_submit(request: Request, csrf_token: str = Form(...
db.close()
CONSULTANT_PORTAL_TABS = {
"overview": "modules/consultants/templates/consultants/portal_partials/overview.html",
"assigned-work": "modules/consultants/templates/consultants/portal_partials/assigned_work.html",
"clients": "modules/consultants/templates/consultants/portal_partials/clients.html",
"documents": "modules/consultants/templates/consultants/portal_partials/documents.html",
"clarifications": "modules/consultants/templates/consultants/portal_partials/clarifications.html",
"requests": "modules/consultants/templates/consultants/portal_partials/service_requests.html",
"reports": "modules/consultants/templates/consultants/portal_partials/reports.html",
}
def _consultant_empty_payload() -> dict:
return {
"links": [],
"active_links": [],
"comments": [],
"recent_firm_messages": [],
"recent_consultant_replies": [],
"managed_clients": [],
"workspace": None,
"workspace_alerts": [],
"service_requests": [],
"open_service_requests": [],
"pending_service_requests": [],
"conversion_requests": [],
"pending_conversions": [],
"due_items": [],
"overdue_items": [],
"workspace_summary": {
"workspace": None,
"managed_clients_used": 0,
"managed_clients_limit": None,
"managed_clients_remaining": None,
"usage_percent": 0,
},
"work_board": {"columns": {}, "column_options": [], "total_tasks": 0, "service_requests": []},
"document_centre": {"engagement_documents": [], "permanent_documents": [], "total": 0},
"stats": {
"linked_clients": 0,
"communication_enabled_clients": 0,
"due_date_enabled_clients": 0,
"pending_clarifications": 0,
"consultant_replies": 0,
"upcoming_due": 0,
"overdue": 0,
"open_service_requests": 0,
"pending_service_requests": 0,
"pending_conversions": 0,
"converted_clients": 0,
"managed_clients": 0,
"prospects": 0,
"referred_to_firm": 0,
},
}
def _build_consultant_portal_payload(db, consultant) -> dict:
payload = consultant_dashboard_payload(db, consultant=consultant)
try:
payload["work_board"] = get_consultant_work_board(db, consultant=consultant)
except Exception:
payload["work_board"] = {"columns": {}, "column_options": [], "total_tasks": 0, "service_requests": []}
try:
payload["document_centre"] = get_consultant_document_centre(db, consultant=consultant)
except Exception:
payload["document_centre"] = {"engagement_documents": [], "permanent_documents": [], "total": 0}
return payload
@portal_router.get("/dashboard")
def consultant_dashboard(request: Request):
def consultant_dashboard(request: Request, tab: str = "overview"):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
@@ -1341,17 +1412,8 @@ def consultant_dashboard(request: Request):
return _redirect_denied()
tenant_id = int(getattr(user, "tenant_id", 0) or 0)
consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id)
if not consultant:
return _render(
request,
"modules/consultants/templates/consultants/portal_dashboard.html",
db,
user,
title="Consultant Portal",
consultant=None,
payload={"links": [], "active_links": [], "comments": [], "managed_clients": [], "workspace": None, "workspace_summary": {"workspace": None, "managed_clients_used": 0, "managed_clients_limit": None, "managed_clients_remaining": None, "usage_percent": 0}, "stats": {"linked_clients": 0, "managed_clients": 0, "prospects": 0, "referred_to_firm": 0, "pending_clarifications": 0, "upcoming_due": 0, "overdue": 0}},
)
payload = consultant_dashboard_payload(db, consultant=consultant)
active_tab = tab if tab in CONSULTANT_PORTAL_TABS else "overview"
payload = _build_consultant_portal_payload(db, consultant) if consultant else _consultant_empty_payload()
return _render(
request,
"modules/consultants/templates/consultants/portal_dashboard.html",
@@ -1360,6 +1422,34 @@ def consultant_dashboard(request: Request):
title="Consultant Portal",
consultant=consultant,
payload=payload,
active_tab=active_tab,
)
finally:
db.close()
@portal_router.get("/dashboard/tab/{tab_name}")
def consultant_dashboard_tab(request: Request, tab_name: str):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
if not (_has_perm(db, user, "consultants.portal.view") or _is_consultant(db, user)):
return _redirect_denied()
tenant_id = int(getattr(user, "tenant_id", 0) or 0)
consultant = get_consultant_by_user(db, tenant_id=tenant_id, user_id=user.id)
active_tab = tab_name if tab_name in CONSULTANT_PORTAL_TABS else "overview"
payload = _build_consultant_portal_payload(db, consultant) if consultant else _consultant_empty_payload()
return _render(
request,
CONSULTANT_PORTAL_TABS[active_tab],
db,
user,
title="Consultant Portal",
consultant=consultant,
payload=payload,
active_tab=active_tab,
)
finally:
db.close()