Improve employee and partner engagement workspace
This commit is contained in:
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user