Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Unified engagement/work detail page for role-wise workspaces."""
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients import repository as client_repository
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile
|
||||
from app.modules.core.rbac.deps import get_user_roles
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.execution import (
|
||||
CLOSED_TASK_STATUSES,
|
||||
TASK_COMMENT_TYPES,
|
||||
TASK_COMMENT_VISIBILITIES,
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
add_task_comment,
|
||||
)
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
MANAGEMENT_ROLES = {"System Admin", "Firm Admin"}
|
||||
PARTNER_ROLES = {"Partner"}
|
||||
MANAGER_ROLES = {"Manager", "Branch Manager"}
|
||||
STAFF_ROLES = {"Staff", "Employee"}
|
||||
CLIENT_ROLES = {"Client"}
|
||||
CONSULTANT_ROLES = {"Consultant"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkAccess:
|
||||
role_context: str
|
||||
can_update_tasks: bool
|
||||
can_comment: bool
|
||||
allowed_comment_types: list[tuple[str, str]]
|
||||
allowed_visibilities: list[tuple[str, str]]
|
||||
back_url: str
|
||||
|
||||
|
||||
def _roles(db: Session, user) -> set[str]:
|
||||
return set(get_user_roles(db, user.id))
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value) if value not in (None, "", "None") else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _current_client_row(db: Session, user):
|
||||
try:
|
||||
return client_repository.get_portal_client_for_user(db, user=user)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _current_consultant(db: Session, user) -> ConsultantProfile | None:
|
||||
return db.execute(
|
||||
select(ConsultantProfile).where(
|
||||
ConsultantProfile.user_id == user.id,
|
||||
ConsultantProfile.tenant_id == user.tenant_id,
|
||||
ConsultantProfile.is_active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _consultant_can_view_engagement(db: Session, *, consultant: ConsultantProfile, engagement: ClientServiceSubscription) -> bool:
|
||||
linked = db.execute(
|
||||
select(ClientConsultantLink.id).where(
|
||||
ClientConsultantLink.tenant_id == consultant.tenant_id,
|
||||
ClientConsultantLink.client_id == engagement.client_id,
|
||||
ClientConsultantLink.consultant_id == consultant.id,
|
||||
ClientConsultantLink.is_active.is_(True),
|
||||
ClientConsultantLink.can_view_communications.is_(True),
|
||||
)
|
||||
).first()
|
||||
if not linked:
|
||||
return False
|
||||
visible_comment = db.execute(
|
||||
select(ServiceTaskComment.id)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.subscription_id == engagement.id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
return bool(visible_comment)
|
||||
|
||||
|
||||
def _is_assigned_staff(engagement: ClientServiceSubscription, tasks: list[ClientServiceTaskInstance], user) -> bool:
|
||||
if _safe_int(getattr(engagement, "assigned_staff_user_id", None)) == user.id:
|
||||
return True
|
||||
return any(_safe_int(getattr(task, "assigned_to_user_id", None)) == user.id for task in tasks)
|
||||
|
||||
|
||||
def _is_manager_for_engagement(engagement: ClientServiceSubscription, user) -> bool:
|
||||
return _safe_int(getattr(engagement, "assigned_manager_user_id", None)) == user.id
|
||||
|
||||
|
||||
def _is_partner_for_engagement(engagement: ClientServiceSubscription, user) -> bool:
|
||||
return user.id in {
|
||||
_safe_int(getattr(engagement, "assigned_partner_user_id", None)),
|
||||
_safe_int(getattr(engagement, "review_partner_user_id", None)),
|
||||
}
|
||||
|
||||
|
||||
def _engagement_query(engagement_id: int):
|
||||
return (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.client),
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
selectinload(ClientServiceSubscription.review_partner),
|
||||
)
|
||||
.where(ClientServiceSubscription.id == int(engagement_id), ClientServiceSubscription.is_active.is_(True))
|
||||
)
|
||||
|
||||
|
||||
def _load_tasks(db: Session, engagement: ClientServiceSubscription) -> list[ClientServiceTaskInstance]:
|
||||
return db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == engagement.tenant_id,
|
||||
ClientServiceTaskInstance.subscription_id == engagement.id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def _load_documents(db: Session, engagement: ClientServiceSubscription) -> tuple[list[EngagementDocument], list[PermanentClientDocument]]:
|
||||
engagement_documents = db.execute(
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == engagement.tenant_id,
|
||||
EngagementDocument.client_id == engagement.client_id,
|
||||
EngagementDocument.engagement_id == engagement.id,
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc())
|
||||
).unique().scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == engagement.tenant_id,
|
||||
PermanentClientDocument.client_id == engagement.client_id,
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
|
||||
.limit(50)
|
||||
).unique().scalars().all()
|
||||
return engagement_documents, permanent_documents
|
||||
|
||||
|
||||
def _load_timeline(db: Session, engagement: ClientServiceSubscription, access: WorkAccess) -> list[ServiceTaskComment]:
|
||||
stmt = (
|
||||
select(ServiceTaskComment)
|
||||
.options(selectinload(ServiceTaskComment.created_by), selectinload(ServiceTaskComment.task))
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == engagement.tenant_id,
|
||||
ServiceTaskComment.subscription_id == engagement.id,
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if access.role_context == "client":
|
||||
stmt = stmt.where(ServiceTaskComment.visibility == "client")
|
||||
elif access.role_context == "consultant":
|
||||
stmt = stmt.where(ServiceTaskComment.visibility == "consultant")
|
||||
return db.execute(stmt.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())).scalars().all()
|
||||
|
||||
|
||||
def _build_access(db: Session, *, user, engagement: ClientServiceSubscription, tasks: list[ClientServiceTaskInstance]) -> WorkAccess | None:
|
||||
roles = _roles(db, user)
|
||||
if roles.intersection(MANAGEMENT_ROLES):
|
||||
return WorkAccess("admin", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/services/work-tracker")
|
||||
|
||||
if roles.intersection(CLIENT_ROLES):
|
||||
client_row = _current_client_row(db, user)
|
||||
if client_row and int(client_row.get("id") or 0) == engagement.client_id and int(client_row.get("tenant_id") or 0) == engagement.tenant_id:
|
||||
return WorkAccess("client", False, True, [("client_clarification", "Client Clarification")], [("client", "Client")], "/client/compliance")
|
||||
return None
|
||||
|
||||
if roles.intersection(CONSULTANT_ROLES):
|
||||
consultant = _current_consultant(db, user)
|
||||
if consultant and _consultant_can_view_engagement(db, consultant=consultant, engagement=engagement):
|
||||
return WorkAccess("consultant", False, True, [("consultant_clarification", "Consultant Clarification")], [("consultant", "Consultant")], "/consultant/work")
|
||||
return None
|
||||
|
||||
if roles.intersection(PARTNER_ROLES) and _is_partner_for_engagement(engagement, user):
|
||||
return WorkAccess("partner", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/partner/reviews")
|
||||
|
||||
if roles.intersection(MANAGER_ROLES):
|
||||
if _is_manager_for_engagement(engagement, user) or (engagement.tenant_id == user.tenant_id and (engagement.branch_id in (None, user.branch_id))):
|
||||
return WorkAccess("manager", True, True, TASK_COMMENT_TYPES, TASK_COMMENT_VISIBILITIES, "/manager/work")
|
||||
|
||||
if roles.intersection(STAFF_ROLES) or roles.intersection({"Employee"}):
|
||||
if _is_assigned_staff(engagement, tasks, user):
|
||||
return WorkAccess("staff", True, True, [("internal_note", "Internal Note"), ("client_clarification", "Client Clarification")], [("internal", "Internal"), ("client", "Client")], "/employee/work")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_unified_engagement_detail(db: Session, *, request, user, engagement_id: int) -> dict[str, Any] | None:
|
||||
engagement = db.execute(_engagement_query(engagement_id)).scalar_one_or_none()
|
||||
if not engagement:
|
||||
return None
|
||||
roles = _roles(db, user)
|
||||
if "System Admin" not in roles and engagement.tenant_id != user.tenant_id:
|
||||
return None
|
||||
|
||||
tasks = _load_tasks(db, engagement)
|
||||
access = _build_access(db, user=user, engagement=engagement, tasks=tasks)
|
||||
if not access:
|
||||
return None
|
||||
|
||||
engagement_documents, permanent_documents = _load_documents(db, engagement)
|
||||
timeline = _load_timeline(db, engagement, access)
|
||||
today = date.today()
|
||||
for task in tasks:
|
||||
status = (task.status or "pending").lower()
|
||||
task.status_label = dict(TASK_STATUSES).get(status, status.replace("_", " ").title())
|
||||
task.priority_label = dict(TASK_PRIORITIES).get(task.priority or "normal", (task.priority or "normal").replace("_", " ").title())
|
||||
task.is_closed_display = status in CLOSED_TASK_STATUSES
|
||||
task.is_overdue_display = bool(task.internal_target_date and task.internal_target_date < today and status not in CLOSED_TASK_STATUSES)
|
||||
task.comments_visible_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
|
||||
|
||||
status_counts: dict[str, int] = {code: 0 for code, _ in TASK_STATUSES}
|
||||
for task in tasks:
|
||||
status_counts[(task.status or "pending").lower()] = status_counts.get((task.status or "pending").lower(), 0) + 1
|
||||
|
||||
return {
|
||||
"engagement": engagement,
|
||||
"tasks": tasks,
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
"timeline": timeline,
|
||||
"access": access,
|
||||
"role_context": access.role_context,
|
||||
"task_statuses": TASK_STATUSES,
|
||||
"task_priorities": TASK_PRIORITIES,
|
||||
"status_counts": status_counts,
|
||||
"open_task_count": sum(1 for task in tasks if (task.status or "pending").lower() not in CLOSED_TASK_STATUSES),
|
||||
"completed_task_count": sum(1 for task in tasks if (task.status or "pending").lower() in CLOSED_TASK_STATUSES),
|
||||
}
|
||||
|
||||
|
||||
def get_task_for_action(db: Session, *, user, task_id: int) -> tuple[ClientServiceTaskInstance | None, WorkAccess | None]:
|
||||
task = db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(selectinload(ClientServiceTaskInstance.subscription), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.client))
|
||||
.where(ClientServiceTaskInstance.id == int(task_id), ClientServiceTaskInstance.is_active.is_(True))
|
||||
).scalar_one_or_none()
|
||||
if not task or not task.subscription:
|
||||
return None, None
|
||||
detail = load_unified_engagement_detail(db, request=None, user=user, engagement_id=task.subscription_id)
|
||||
if not detail:
|
||||
return None, None
|
||||
return task, detail["access"]
|
||||
|
||||
|
||||
def save_task_status(db: Session, *, task: ClientServiceTaskInstance, status: str, priority: str | None, user_id: int) -> None:
|
||||
allowed_statuses = {code for code, _ in TASK_STATUSES}
|
||||
allowed_priorities = {code for code, _ in TASK_PRIORITIES}
|
||||
clean_status = (status or task.status or "pending").strip().lower()
|
||||
clean_priority = (priority or task.priority or "normal").strip().lower()
|
||||
if clean_status in allowed_statuses:
|
||||
task.status = clean_status
|
||||
if clean_priority in allowed_priorities:
|
||||
task.priority = clean_priority
|
||||
if task.status == "in_progress" and not task.started_at_utc:
|
||||
task.started_at_utc = datetime.now(timezone.utc)
|
||||
if task.status in CLOSED_TASK_STATUSES and not task.completed_at_utc:
|
||||
task.completed_at_utc = datetime.now(timezone.utc)
|
||||
task.updated_by_user_id = user_id
|
||||
|
||||
|
||||
def save_task_comment(db: Session, *, task: ClientServiceTaskInstance, access: WorkAccess, comment_type: str, visibility: str, message: str, user_id: int) -> bool:
|
||||
allowed_comment_types = {code for code, _ in access.allowed_comment_types}
|
||||
allowed_visibilities = {code for code, _ in access.allowed_visibilities}
|
||||
clean_type = comment_type if comment_type in allowed_comment_types else next(iter(allowed_comment_types), "internal_note")
|
||||
clean_visibility = visibility if visibility in allowed_visibilities else next(iter(allowed_visibilities), "internal")
|
||||
row = add_task_comment(db, task=task, comment_type=clean_type, visibility=clean_visibility, message=message, user_id=user_id)
|
||||
return row is not None
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% if role_context == 'client' %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" ignore missing %}
|
||||
{% elif role_context == 'consultant' %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" ignore missing %}
|
||||
{% elif role_context == 'partner' %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" ignore missing %}
|
||||
{% elif role_context == 'manager' %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" ignore missing %}
|
||||
{% elif role_context == 'staff' %}
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" ignore missing %}
|
||||
{% endif %}
|
||||
|
||||
{% set client = engagement.client %}
|
||||
{% set catalogue = engagement.catalogue %}
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Unified Work Details</div>
|
||||
<h2 class="mt-1 text-2xl font-semibold text-slate-900">{{ catalogue.service_name if catalogue else 'Engagement' }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ client.client_name if client else 'Client' }}{% if client and client.client_code %} • {{ client.client_code }}{% endif %}
|
||||
{% if engagement.financial_year %} • FY {{ engagement.financial_year }}{% endif %}
|
||||
{% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-slate-700">{{ engagement.status.replace('_',' ').title() }}</span>
|
||||
{% if engagement.current_due_date %}<span class="rounded-full bg-amber-50 px-3 py-1 text-amber-700">Due {{ engagement.current_due_date.strftime('%d-%m-%Y') }}</span>{% endif %}
|
||||
<a href="{{ access.back_url }}" class="rounded-full border border-slate-300 px-3 py-1 text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-4">
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Open Tasks</div><div class="mt-1 text-2xl font-semibold">{{ open_task_count }}</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Completed</div><div class="mt-1 text-2xl font-semibold">{{ completed_task_count }}</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Engagement Docs</div><div class="mt-1 text-2xl font-semibold">{{ engagement_documents|length }}</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Timeline Notes</div><div class="mt-1 text-2xl font-semibold">{{ timeline|length }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<h3 class="font-semibold text-slate-900">Task Board</h3>
|
||||
<p class="text-xs text-slate-500">Role-sensitive task view. Clients and consultants see only permitted action/comment options.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for task in tasks %}
|
||||
<div class="p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ task.sequence_no }}. {{ task.task_name }}</div>
|
||||
{% if task.description %}<div class="mt-1 text-sm text-slate-500">{{ task.description }}</div>{% endif %}
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-[11px] font-semibold">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.status_label }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.priority_label }}</span>
|
||||
{% if task.internal_target_date %}<span class="rounded-full {% if task.is_overdue_display %}bg-red-50 text-red-700{% else %}bg-slate-100 text-slate-700{% endif %} px-2 py-1">Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %}
|
||||
{% if task.assigned_to %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Assigned: {{ task.assigned_to.full_name or task.assigned_to.email }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if access.can_update_tasks %}
|
||||
<form method="post" action="/work/tasks/{{ task.id }}/status" class="flex flex-wrap gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-2 py-1 text-xs">
|
||||
{% for code, label in task_statuses %}<option value="{{ code }}" {% if task.status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="priority" class="rounded-xl border border-slate-300 px-2 py-1 text-xs">
|
||||
{% for code, label in task_priorities %}<option value="{{ code }}" {% if task.priority == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl bg-brand-600 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-700">Update</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if access.can_comment %}
|
||||
<form method="post" action="/work/tasks/{{ task.id }}/comment" class="mt-4 rounded-2xl bg-slate-50 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-2 md:grid-cols-[auto_auto_1fr_auto]">
|
||||
<select name="comment_type" class="rounded-xl border border-slate-300 px-2 py-2 text-xs">
|
||||
{% for code, label in access.allowed_comment_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="visibility" class="rounded-xl border border-slate-300 px-2 py-2 text-xs">
|
||||
{% for code, label in access.allowed_visibilities %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<input name="message" required placeholder="Add note / clarification / submission update" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No tasks have been generated for this engagement yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Communication Timeline</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for note in timeline %}
|
||||
<div class="p-5">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span class="font-semibold text-slate-700">{{ note.comment_type.replace('_',' ').title() }}</span>
|
||||
<span>•</span><span>{{ note.visibility.replace('_',' ').title() }}</span>
|
||||
{% if note.created_by %}<span>•</span><span>{{ note.created_by.full_name or note.created_by.email }}</span>{% endif %}
|
||||
{% if note.created_at_utc %}<span>•</span><span>{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') }}</span>{% endif %}
|
||||
{% if note.task %}<span>•</span><span>{{ note.task.task_name }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-2 whitespace-pre-wrap text-sm text-slate-700">{{ note.message }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No communication yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-5 text-sm text-slate-500">No engagement documents uploaded.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ doc.category }} • v{{ doc.current_version_no }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-5 text-sm text-slate-500">No permanent documents available.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.work_detail.service import get_task_for_action, load_unified_engagement_detail, save_task_comment, save_task_status
|
||||
|
||||
router = APIRouter(prefix="/work", tags=["unified-work-detail-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, db, user, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _fallback_for_user(db, user) -> str:
|
||||
roles = set(get_user_roles(db, user.id))
|
||||
if "Client" in roles:
|
||||
return "/client/compliance"
|
||||
if "Consultant" in roles:
|
||||
return "/consultant/work"
|
||||
if "Partner" in roles:
|
||||
return "/partner/reviews"
|
||||
if roles.intersection({"Manager", "Branch Manager"}):
|
||||
return "/manager/work"
|
||||
return "/employee/work"
|
||||
|
||||
|
||||
@router.get("/engagements/{engagement_id}")
|
||||
def unified_engagement_detail(request: Request, engagement_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
detail = load_unified_engagement_detail(db, request=request, user=user, engagement_id=engagement_id)
|
||||
if not detail:
|
||||
return RedirectResponse(url=_fallback_for_user(db, user), status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"modules/work_detail/templates/work_detail/engagement_detail.html",
|
||||
_base_ctx(request, db, user, title="Work Details", **detail),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/status")
|
||||
async def unified_task_status_update(request: Request, task_id: int, csrf_token: str = Form(...), status: str = Form(...), priority: str = Form("")):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
task, access = get_task_for_action(db, user=user, task_id=task_id)
|
||||
if not task or not access:
|
||||
return RedirectResponse(url=_fallback_for_user(db, user), status_code=303)
|
||||
if not access.can_update_tasks:
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=not_allowed", status_code=303)
|
||||
save_task_status(db, task=task, status=status, priority=priority, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?updated=1", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/comment")
|
||||
async def unified_task_comment_add(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
csrf_token: str = Form(...),
|
||||
comment_type: str = Form("internal_note"),
|
||||
visibility: str = Form("internal"),
|
||||
message: str = Form(""),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
task, access = get_task_for_action(db, user=user, task_id=task_id)
|
||||
if not task or not access:
|
||||
return RedirectResponse(url=_fallback_for_user(db, user), status_code=303)
|
||||
if not access.can_comment:
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=comment_not_allowed", status_code=303)
|
||||
ok = save_task_comment(db, task=task, access=access, comment_type=comment_type, visibility=visibility, message=message, user_id=user.id)
|
||||
if ok:
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?comment=sent", status_code=303)
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/work/engagements/{task.subscription_id}?error=empty_comment", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user