Add inline documents and messages to I1 checklist
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services/engagements/{{ engagement.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Engagement</a>
|
||||
<a href="{{ back_url }}" class="rounded-xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-100">Back to Engagement Task</a>
|
||||
<a href="/documents/engagements/{{ engagement.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Engagement Documents</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +51,7 @@
|
||||
<h3 class="text-sm font-semibold text-slate-900">Upload Task Document</h3>
|
||||
<form method="post" action="/documents/tasks/{{ task.id }}/upload" enctype="multipart/form-data" class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<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">Requirement</label>
|
||||
<select name="requirement_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
|
||||
@@ -272,6 +272,13 @@ def _require_user(request: Request, db, permission_code: str):
|
||||
|
||||
|
||||
|
||||
def _safe_documents_return_url(value: str | None, fallback: str) -> str:
|
||||
candidate = (value or "").strip()
|
||||
if candidate.startswith("/") and not candidate.startswith("//"):
|
||||
return candidate
|
||||
return fallback
|
||||
|
||||
|
||||
def _active_financial_year(request: Request) -> str | None:
|
||||
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
@@ -440,6 +447,8 @@ def task_documents(request: Request, task_id: int):
|
||||
requirement_status=requirement_status,
|
||||
documents=documents,
|
||||
can_upload=user_can_upload_to_engagement(db, user, engagement, scope),
|
||||
return_to=_safe_documents_return_url(request.query_params.get("return_to"), f"/documents/engagements/{engagement.id}"),
|
||||
back_url=_safe_documents_return_url(request.query_params.get("return_to"), f"/documents/engagements/{engagement.id}"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -459,11 +468,13 @@ def upload_task_document(
|
||||
evidence_type: str | None = Form(None),
|
||||
evidence_description: str | None = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
return_to: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return_target = _safe_documents_return_url(return_to, f"/documents/tasks/{task_id}")
|
||||
user, response = _require_user(request, db, "documents.upload")
|
||||
if response:
|
||||
return response
|
||||
@@ -478,12 +489,12 @@ def upload_task_document(
|
||||
if is_row_financial_year_locked(db, engagement):
|
||||
return RedirectResponse(url=f"/documents/engagements/{engagement.id}?year_locked=1", status_code=303)
|
||||
if not file or not file.filename:
|
||||
return RedirectResponse(url=f"/documents/tasks/{task_id}?error=missing_file", status_code=303)
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/tasks/{task_id}?error=missing_file", status_code=303)
|
||||
requirement = None
|
||||
if requirement_id and str(requirement_id).strip():
|
||||
requirement = get_task_document_requirement(db, requirement_id=int(requirement_id), tenant_id=task.tenant_id)
|
||||
if not requirement or requirement.firm_task_template_id != task.firm_task_template_id:
|
||||
return RedirectResponse(url=f"/documents/tasks/{task_id}?error=invalid_requirement", status_code=303)
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/tasks/{task_id}?error=invalid_requirement", status_code=303)
|
||||
try:
|
||||
doc = save_uploaded_task_document(
|
||||
db,
|
||||
@@ -511,8 +522,8 @@ def upload_task_document(
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/documents/tasks/{task_id}?error=upload_failed", status_code=303)
|
||||
return RedirectResponse(url=f"/documents/tasks/{task_id}?uploaded=1", status_code=303)
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/tasks/{task_id}?error=upload_failed", status_code=303)
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/tasks/{task_id}?uploaded=1", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -3544,6 +3544,12 @@ def _i1_prepare_task_display(
|
||||
task.i1_review_label = _i1_review_label(task)
|
||||
task.i1_approval_blocker = _i1_approval_blocker(tasks, task)
|
||||
task.i1_has_evidence = task_document_counts.get(int(task.id), 0) > 0
|
||||
task.i1_documents = []
|
||||
task.i1_communications = [
|
||||
comment
|
||||
for comment in getattr(task, "comments", [])
|
||||
if not getattr(comment, "is_deleted", False)
|
||||
]
|
||||
task.i1_needs_response = bool(
|
||||
getattr(task, "response_required", False)
|
||||
or (getattr(task, "response_type", "NONE") or "NONE").strip().upper() != "NONE"
|
||||
@@ -3846,12 +3852,19 @@ def get_employee_engagement_work_board(
|
||||
if task_instance_id:
|
||||
task_document_counts[int(task_instance_id)] = task_document_counts.get(int(task_instance_id), 0) + 1
|
||||
|
||||
task_documents: dict[int, list[EngagementDocument]] = {}
|
||||
for document in engagement_documents:
|
||||
task_instance_id = getattr(document, "task_instance_id", None)
|
||||
if task_instance_id:
|
||||
task_documents.setdefault(int(task_instance_id), []).append(document)
|
||||
|
||||
for task in tasks:
|
||||
_i1_prepare_task_display(
|
||||
task,
|
||||
tasks=tasks,
|
||||
task_document_counts=task_document_counts,
|
||||
)
|
||||
task.i1_documents = task_documents.get(int(task.id), [])
|
||||
|
||||
scope_display = _employee_work_scope_display_map(
|
||||
db,
|
||||
@@ -3881,6 +3894,8 @@ def get_employee_engagement_work_board(
|
||||
"documents": engagement_documents,
|
||||
"scope_display": scope_display,
|
||||
"i1_enabled": True,
|
||||
"communication_types": TASK_COMMUNICATION_TYPES,
|
||||
"communication_visibilities": TASK_COMMUNICATION_VISIBILITIES,
|
||||
"today": today,
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,90 @@
|
||||
<span class="rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-500">🔒 Locked</span>
|
||||
{% endif %}
|
||||
|
||||
<details class="group w-full lg:w-auto">
|
||||
<summary class="list-none cursor-pointer rounded-xl border border-violet-200 bg-violet-50 px-3 py-2 text-center text-sm font-semibold text-violet-700 hover:bg-violet-100">Documents{% if task.i1_documents %} · {{ task.i1_documents|length }}{% endif %}</summary>
|
||||
<div class="mt-3 rounded-2xl border border-violet-200 bg-white p-4 lg:min-w-[620px]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="font-semibold text-slate-900">Task Documents</h5>
|
||||
<p class="mt-1 text-xs text-slate-500">Files stay linked to this task and engagement.</p>
|
||||
</div>
|
||||
<a href="/documents/tasks/{{ task.id }}?return_to=/employee/work/engagements/{{ board.engagement_id }}%3Ftask_id%3D{{ task.id }}%23task-{{ task.id }}" class="text-xs font-semibold text-violet-700 hover:underline">Open full document view</a>
|
||||
</div>
|
||||
|
||||
{% if task.i1_documents %}
|
||||
<div class="mt-3 divide-y divide-slate-100 rounded-xl border border-slate-200">
|
||||
{% for doc in task.i1_documents[:5] %}
|
||||
{% set latest = doc.versions[0] if doc.versions else none %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-slate-800">{{ doc.title }}</div>
|
||||
<div class="truncate text-xs text-slate-500">{{ latest.original_filename if latest else doc.document_type }}{% if latest %} · v{{ latest.version_no }}{% endif %}</div>
|
||||
</div>
|
||||
{% if latest %}<a href="/documents/{{ doc.id }}/download" class="text-xs font-semibold text-brand-700 hover:underline">Download</a>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-3 rounded-xl border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-500">No document attached yet.</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/documents/tasks/{{ task.id }}/upload" enctype="multipart/form-data" class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="title" value="">
|
||||
<input type="hidden" name="document_type" value="GENERAL">
|
||||
<input type="hidden" name="return_to" value="/employee/work/engagements/{{ board.engagement_id }}?task_id={{ task.id }}#task-{{ task.id }}">
|
||||
<label class="min-w-0 flex-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Add file
|
||||
<input type="file" name="file" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
</label>
|
||||
<button type="submit" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-700">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group w-full lg:w-auto">
|
||||
<summary class="list-none cursor-pointer rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-center text-sm font-semibold text-sky-700 hover:bg-sky-100">Message / Clarification{% if task.i1_communications %} · {{ task.i1_communications|length }}{% endif %}</summary>
|
||||
<div class="mt-3 rounded-2xl border border-sky-200 bg-white p-4 lg:min-w-[620px]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h5 class="font-semibold text-slate-900">Messages / Clarifications</h5>
|
||||
<p class="mt-1 text-xs text-slate-500">Add a note, clarification or client/consultant communication.</p>
|
||||
</div>
|
||||
<a href="/employee/work/tasks/{{ task.id }}/communication?return_to=/employee/work/engagements/{{ board.engagement_id }}%3Ftask_id%3D{{ task.id }}%23task-{{ task.id }}" class="text-xs font-semibold text-sky-700 hover:underline">Open full timeline</a>
|
||||
</div>
|
||||
|
||||
{% if task.i1_communications %}
|
||||
<div class="mt-3 max-h-52 divide-y divide-slate-100 overflow-y-auto rounded-xl border border-slate-200">
|
||||
{% for item in task.i1_communications[:5] %}
|
||||
<div class="px-3 py-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-[11px] text-slate-500">
|
||||
<span>{{ item.comment_type.replace('_',' ').title() }} · {{ item.visibility.replace('_',' ').title() }}</span>
|
||||
<span>{{ item.created_at_utc }}</span>
|
||||
</div>
|
||||
<div class="mt-1 whitespace-pre-wrap text-sm text-slate-800">{{ item.message }}</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">By {{ item.created_by.full_name if item.created_by else 'System/User' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-3 rounded-xl border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-500">No message or clarification yet.</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/employee/work/tasks/{{ task.id }}/communication" class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="return_to" value="/employee/work/engagements/{{ board.engagement_id }}?task_id={{ task.id }}#task-{{ task.id }}">
|
||||
<select name="comment_type" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code, label in board.communication_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="visibility" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code, label in board.communication_visibilities %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<textarea name="message" rows="2" required class="sm:col-span-2 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Type message or clarification"></textarea>
|
||||
<div class="sm:col-span-2 flex justify-end"><button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white hover:bg-sky-700">Send</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group w-full lg:w-auto">
|
||||
<summary class="list-none cursor-pointer rounded-xl border border-slate-300 bg-white px-3 py-2 text-center text-sm font-semibold text-slate-700 hover:bg-slate-50">Details</summary>
|
||||
<div class="mt-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 lg:min-w-[720px]">
|
||||
@@ -243,10 +327,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/documents/tasks/{{ task.id }}" class="rounded-lg border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700">Evidence{% if task.i1_has_evidence %} ✓{% endif %}</a>
|
||||
<a href="/employee/work/tasks/{{ task.id }}/communication" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700">Communication{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Documents and messages can be added directly from this checklist row.</div>
|
||||
<button type="submit" class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Details</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<h3 class="font-semibold text-slate-900">Add communication</h3>
|
||||
<form method="post" action="{{ post_url }}" class="mt-4 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="return_to" value="{{ return_to or back_url }}">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Type
|
||||
<select name="comment_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
|
||||
@@ -505,6 +505,13 @@ def employee_hr_dashboard(request: Request):
|
||||
db.close()
|
||||
|
||||
|
||||
def _safe_employee_return_url(value: str | None, fallback: str) -> str:
|
||||
candidate = (value or "").strip()
|
||||
if candidate.startswith("/") and not candidate.startswith("//"):
|
||||
return candidate
|
||||
return fallback
|
||||
|
||||
|
||||
@router.get("")
|
||||
def employees_list(request: Request, q: str = "", include_inactive: str | None = None, link_status: str = "all"):
|
||||
db = CommonSessionLocal()
|
||||
@@ -2348,7 +2355,8 @@ def employee_work_task_communication(
|
||||
communication_types=TASK_COMMUNICATION_TYPES,
|
||||
communication_visibilities=TASK_COMMUNICATION_VISIBILITIES,
|
||||
post_url=f"/employees/work/tasks/{task_id}/communication",
|
||||
back_url="/employees/work",
|
||||
back_url=_safe_employee_return_url(request.query_params.get("return_to"), "/employees/work"),
|
||||
return_to=_safe_employee_return_url(request.query_params.get("return_to"), "/employees/work"),
|
||||
is_employee_self=False,
|
||||
errors=[],
|
||||
)
|
||||
@@ -2363,6 +2371,7 @@ def employee_work_task_add_communication(
|
||||
comment_type: str = Form("internal_note"),
|
||||
visibility: str = Form("internal"),
|
||||
message: str = Form(...),
|
||||
return_to: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
@@ -2391,7 +2400,7 @@ def employee_work_task_add_communication(
|
||||
actor_user_id=current_user.id,
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
return RedirectResponse(url=f"/employees/work/tasks/{task_id}/communication", status_code=303)
|
||||
return RedirectResponse(url=_safe_employee_return_url(return_to, f"/employees/work/tasks/{task_id}/communication"), status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2425,7 +2434,8 @@ def employee_my_work_task_communication(
|
||||
communication_types=TASK_COMMUNICATION_TYPES,
|
||||
communication_visibilities=TASK_COMMUNICATION_VISIBILITIES,
|
||||
post_url=f"/employee/work/tasks/{task_id}/communication",
|
||||
back_url="/employee/work",
|
||||
back_url=_safe_employee_return_url(request.query_params.get("return_to"), "/employee/work"),
|
||||
return_to=_safe_employee_return_url(request.query_params.get("return_to"), "/employee/work"),
|
||||
is_employee_self=True,
|
||||
errors=[],
|
||||
financial_year=financial_year,
|
||||
@@ -2441,6 +2451,7 @@ def employee_my_work_task_add_communication(
|
||||
comment_type: str = Form("internal_note"),
|
||||
visibility: str = Form("internal"),
|
||||
message: str = Form(...),
|
||||
return_to: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
@@ -2470,7 +2481,7 @@ def employee_my_work_task_add_communication(
|
||||
assigned_only=True,
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
return RedirectResponse(url=f"/employee/work/tasks/{task_id}/communication", status_code=303)
|
||||
return RedirectResponse(url=_safe_employee_return_url(return_to, f"/employee/work/tasks/{task_id}/communication"), status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user