Improve employee and partner engagement workspace

This commit is contained in:
A R R R Associates
2026-09-02 12:31:27 +05:30
parent fbb9a62eb8
commit 2a4fd2bfdd
6 changed files with 259 additions and 1 deletions
+4
View File
@@ -25,6 +25,7 @@ from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
from app.modules.documents.models import EngagementDocument from app.modules.documents.models import EngagementDocument
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment, FirmTaskDocumentRequirement
from app.modules.registrations.models import ClientRegistration, RegistrationType from app.modules.registrations.models import ClientRegistration, RegistrationType
from app.modules.services.engagement_resources import build_engagement_resource_context
from app.modules.services.execution import ( from app.modules.services.execution import (
CLOSED_TASK_STATUSES, CLOSED_TASK_STATUSES,
TASK_PRIORITIES, TASK_PRIORITIES,
@@ -4164,6 +4165,8 @@ def get_employee_engagement_work_board(
and int(task.firm_task_template_id) in mandatory_requirement_template_ids and int(task.firm_task_template_id) in mandatory_requirement_template_ids
) )
resource_context = build_engagement_resource_context(db, subscription, tasks, engagement_documents) if subscription is not None else {}
scope_display = _employee_work_scope_display_map( scope_display = _employee_work_scope_display_map(
db, db,
tenant_id=scope.tenant_id, tenant_id=scope.tenant_id,
@@ -4191,6 +4194,7 @@ def get_employee_engagement_work_board(
"active_category": selected_category, "active_category": selected_category,
"next_task": next_task, "next_task": next_task,
"documents": engagement_documents, "documents": engagement_documents,
"resources": resource_context,
"scope_display": scope_display, "scope_display": scope_display,
"i1_enabled": True, "i1_enabled": True,
"communication_types": TASK_COMMUNICATION_TYPES, "communication_types": TASK_COMMUNICATION_TYPES,
@@ -66,6 +66,11 @@
</form> </form>
</div> </div>
{% set resources = board.resources or {} %}
{% set engagement_id = board.engagement_id %}
{% set return_to = '/employee/work/engagements/' ~ board.engagement_id %}
{% include "modules/services/templates/services/engagements/_engagement_resources.html" %}
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"> <div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Progress</div> <div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Progress</div>
@@ -14,6 +14,10 @@
</div> </div>
</div> </div>
{% set engagement_id = engagement.id %}
{% set return_to = '/partner/engagements/' ~ engagement.id %}
{% include "modules/services/templates/services/engagements/_engagement_resources.html" %}
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]"> <div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section class="space-y-4"> <section class="space-y-4">
{% for task in tasks %} {% for task in tasks %}
+4 -1
View File
@@ -19,6 +19,7 @@ from app.modules.documents.models import EngagementDocument
from app.modules.clients.models import Client, ClientBranch, ClientBusinessUnit from app.modules.clients.models import Client, ClientBranch, ClientBusinessUnit
from app.modules.registrations.models import ClientRegistration, RegistrationType from app.modules.registrations.models import ClientRegistration, RegistrationType
from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES
from app.modules.services.engagement_resources import build_engagement_resource_context
from app.modules.services.models import ( from app.modules.services.models import (
ClientServiceSubscription, ClientServiceSubscription,
ClientServiceTaskInstance, ClientServiceTaskInstance,
@@ -709,11 +710,13 @@ def partner_engagement_detail(request: Request, engagement_id: int):
_decorate_task(task, today) _decorate_task(task, today)
documents = db.execute( documents = db.execute(
select(EngagementDocument) select(EngagementDocument)
.options(selectinload(EngagementDocument.versions))
.where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False)) .where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False))
.order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc()) .order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc())
).scalars().all() ).scalars().all()
engagement.display_label = _engagement_label(engagement) engagement.display_label = _engagement_label(engagement)
return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, errors=[], financial_year=financial_year) resources = build_engagement_resource_context(db, engagement, tasks, documents)
return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, resources=resources, errors=[], financial_year=financial_year)
finally: finally:
db.close() db.close()
@@ -0,0 +1,85 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Iterable
from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
def _join_local_path(root: str, relative: str) -> str:
root = (root or "").strip().rstrip("\\/")
if not root:
return ""
if re.match(r"^[A-Za-z]:", root) or "\\" in root:
return root + "\\" + relative.replace("/", "\\")
return root + "/" + relative.lstrip("/")
def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], documents: Iterable[Any]) -> dict[str, Any]:
"""Presentation-only engagement resource data using existing storage/task models.
No new persistence is introduced here. Accounting paths follow the same FY/client
folder convention already used by the ERP Local Agent. Task-category uploads map
to an existing task so evidence/AQMM/document controls remain authoritative.
"""
tasks = list(tasks or [])
documents = list(documents or [])
client = getattr(engagement, "client", None)
client_id = int(getattr(engagement, "client_id", 0) or 0)
fy = str(getattr(engagement, "financial_year", "") or "").strip()
accounting_relative_dir = ""
accounting_relative_path = ""
accounting_local_path = ""
storage_root_path = ""
storage_node_name = ""
if client and client_id and fy:
fy_folder = sanitize_segment(f"FY{fy}", "FY")
letter, client_folder = client_folder_parts(client, client_id)
relative_dir = Path(fy_folder) / "Clients" / letter / client_folder / "Accounting"
accounting_relative_dir = relative_dir.as_posix()
accounting_relative_path = (relative_dir / f"client_{client_id:08d}.act").as_posix()
node = get_active_storage_node_for_branch(
db,
int(getattr(engagement, "tenant_id", 0) or 0),
getattr(engagement, "branch_id", None),
)
if node:
storage_root_path = str(getattr(node, "storage_root_path", "") or "").strip()
storage_node_name = str(getattr(node, "node_name", "") or "").strip()
accounting_local_path = _join_local_path(storage_root_path, accounting_relative_path)
category_map: dict[str, int] = {}
for task in tasks:
category = str(getattr(task, "task_category", "") or "").strip() or "General Workflow"
task_id = int(getattr(task, "id", 0) or 0)
if task_id and category not in category_map:
category_map[category] = task_id
categories = [{"name": name, "task_id": task_id} for name, task_id in category_map.items()]
document_options = []
for doc in documents:
versions = list(getattr(doc, "versions", None) or [])
latest = versions[0] if versions else None
document_options.append({
"id": int(getattr(doc, "id", 0) or 0),
"title": str(getattr(doc, "title", "") or "Document"),
"document_type": str(getattr(doc, "document_type", "GENERAL") or "GENERAL"),
"version_no": int(getattr(doc, "current_version_no", 0) or 0),
"task_instance_id": getattr(doc, "task_instance_id", None),
"latest_filename": str(getattr(latest, "original_filename", "") or "") if latest else "",
"latest_relative_path": str(getattr(latest, "local_relative_path", "") or "") if latest else "",
"storage_status": str(getattr(latest, "storage_status", "") or "") if latest else "",
})
return {
"accounting_relative_dir": accounting_relative_dir,
"accounting_relative_path": accounting_relative_path,
"accounting_local_path": accounting_local_path,
"storage_root_path": storage_root_path,
"storage_node_name": storage_node_name,
"task_categories": categories,
"default_upload_task_id": categories[0]["task_id"] if categories else None,
"document_options": document_options,
}
@@ -0,0 +1,157 @@
{% set resources = resources or {} %}
<section class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="font-semibold text-slate-900">Engagement Data & Documents</h3>
<p class="mt-1 text-sm text-slate-500">Tally/accounting storage and engagement files are available here before the task checklist.</p>
</div>
<a href="/documents/engagements/{{ engagement_id }}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Full Document Register</a>
</div>
</div>
<div class="grid gap-4 p-5 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.8fr)]">
<div class="space-y-4">
<div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Tally / Accounting Data</div>
<div class="mt-1 font-semibold text-indigo-950">Client accounting .act path</div>
</div>
{% if resources.storage_node_name %}<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-indigo-700">{{ resources.storage_node_name }}</span>{% endif %}
</div>
{% if resources.accounting_local_path %}
<div class="mt-3 flex gap-2">
<input id="engagement-accounting-path" readonly value="{{ resources.accounting_local_path }}" class="min-w-0 flex-1 rounded-xl border border-indigo-200 bg-white px-3 py-2 font-mono text-xs text-slate-700">
<button type="button" data-copy-target="engagement-accounting-path" class="rounded-xl bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Copy Path</button>
</div>
<p class="mt-2 text-xs text-indigo-700">This follows the existing branch Storage Node → FY → Client → Accounting convention used by the Local Agent.</p>
{% elif resources.accounting_relative_path %}
<div class="mt-3 rounded-xl border border-indigo-200 bg-white px-3 py-2 font-mono text-xs text-slate-700">{{ resources.accounting_relative_path }}</div>
<p class="mt-2 text-xs text-amber-700">The relative accounting path is known, but this branch does not currently expose a configured local storage root.</p>
{% else %}
<p class="mt-3 text-sm text-slate-600">Accounting path is unavailable for this engagement.</p>
{% endif %}
</div>
<div>
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-slate-900">Current Engagement Files</div>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600">{{ resources.document_options|length }}</span>
</div>
<div class="max-h-72 space-y-2 overflow-y-auto pr-1">
{% for doc in resources.document_options %}
<div class="rounded-xl border border-slate-200 p-3">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-900">{{ doc.title }}</div>
<div class="mt-1 text-xs text-slate-500">{{ doc.document_type|replace('_',' ')|title }} · v{{ doc.version_no }}{% if doc.latest_filename %} · {{ doc.latest_filename }}{% endif %}</div>
</div>
{% if doc.storage_status %}<span class="rounded-full bg-slate-100 px-2 py-1 text-[11px] font-semibold text-slate-600">{{ doc.storage_status|replace('_',' ')|title }}</span>{% endif %}
</div>
{% if doc.latest_relative_path %}<div class="mt-2 break-all font-mono text-[11px] text-slate-400">{{ doc.latest_relative_path }}</div>{% endif %}
</div>
{% else %}
<div class="rounded-xl border border-dashed border-slate-300 p-5 text-center text-sm text-slate-500">No engagement documents uploaded yet.</div>
{% endfor %}
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="text-sm font-semibold text-slate-900">Upload from Engagement Page</div>
<p class="mt-1 text-xs text-slate-500">Choose the task category so the file remains linked to the existing task/evidence workflow.</p>
{% if resources.default_upload_task_id %}
<form method="post" enctype="multipart/form-data" action="/documents/tasks/{{ resources.default_upload_task_id }}/upload" class="mt-4 space-y-3" data-engagement-upload-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="return_to" value="{{ return_to }}">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Task Category</label>
<select class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-upload-task-select>
{% for category in resources.task_categories %}<option value="{{ category.task_id }}">{{ category.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Upload Action</label>
<select class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-upload-mode>
<option value="new">New Document</option>
<option value="replace">Replace Current Document</option>
<option value="version">Add Another Version</option>
</select>
</div>
<div data-existing-document-wrap class="hidden">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Existing Document</label>
<select name="existing_document_id" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-existing-document-select>
<option value="">Select document</option>
{% for doc in resources.document_options %}<option value="{{ doc.id }}">{{ doc.title }} · v{{ doc.version_no }}</option>{% endfor %}
</select>
<p class="mt-1 text-[11px] text-slate-500">Replacement/version uploads use the existing document container and retain the ERP audit/version history.</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Document Type</label>
<select name="document_type" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
{% for item in ['GENERAL','CLIENT_DOCUMENT','WORKING_PAPER','BANK_STATEMENT','TRIAL_BALANCE','LEDGER','GST_RETURN','INCOME_TAX','ROC','AUDIT_REPORT','SIGNED_OUTPUT','ACKNOWLEDGEMENT'] %}<option value="{{ item }}">{{ item|replace('_',' ')|title }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Title</label>
<input name="title" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" placeholder="Defaults to file name">
</div>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">File</label>
<input type="file" name="file" required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Description / Remarks</label>
<input name="remarks" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" placeholder="Optional">
</div>
<button class="w-full rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Upload Document</button>
</form>
{% else %}
<div class="mt-4 rounded-xl border border-dashed border-slate-300 bg-white p-5 text-center text-sm text-slate-500">No task is available to receive an engagement upload.</div>
{% endif %}
</div>
</div>
</section>
<script>
(function () {
const form = document.querySelector('[data-engagement-upload-form]');
if (form) {
const taskSelect = form.querySelector('[data-upload-task-select]');
const mode = form.querySelector('[data-upload-mode]');
const wrap = form.querySelector('[data-existing-document-wrap]');
const existing = form.querySelector('[data-existing-document-select]');
const updateAction = function () {
form.action = '/documents/tasks/' + taskSelect.value + '/upload';
};
const updateMode = function () {
const useExisting = mode.value !== 'new';
wrap.classList.toggle('hidden', !useExisting);
existing.required = useExisting;
if (!useExisting) existing.value = '';
};
taskSelect.addEventListener('change', updateAction);
mode.addEventListener('change', updateMode);
updateAction();
updateMode();
}
document.querySelectorAll('[data-copy-target]').forEach(function (button) {
button.addEventListener('click', async function () {
const field = document.getElementById(button.getAttribute('data-copy-target'));
if (!field) return;
try {
await navigator.clipboard.writeText(field.value);
const oldText = button.textContent;
button.textContent = 'Copied';
setTimeout(function () { button.textContent = oldText; }, 1200);
} catch (err) {
field.select();
document.execCommand('copy');
}
});
});
})();
</script>