Prepare ERP source for Gitea deployment
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Tenant
|
||||
from app.modules.services.models import ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue
|
||||
|
||||
SUBSCRIPTION_STATUSES = [
|
||||
("draft", "Draft"),
|
||||
("active", "Active"),
|
||||
("on_hold", "On Hold"),
|
||||
("completed", "Completed"),
|
||||
("cancelled", "Cancelled"),
|
||||
("inactive", "Inactive"),
|
||||
]
|
||||
|
||||
ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff")
|
||||
|
||||
|
||||
def current_financial_year(today: date | None = None) -> str:
|
||||
today = today or date.today()
|
||||
if today.month >= 4:
|
||||
start = today.year
|
||||
else:
|
||||
start = today.year - 1
|
||||
return f"{start}-{str(start + 1)[-2:]}"
|
||||
|
||||
|
||||
def assessment_year_from_financial_year(financial_year: str | None) -> str | None:
|
||||
if not financial_year or "-" not in financial_year:
|
||||
return None
|
||||
start = int(str(financial_year).split("-")[0])
|
||||
return f"{start + 1}-{str(start + 2)[-2:]}"
|
||||
|
||||
|
||||
def normalize_financial_year(value: str | None) -> str:
|
||||
value = (value or "").strip()
|
||||
return value or current_financial_year()
|
||||
|
||||
|
||||
def parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def list_subscription_payload(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
financial_year: str | None = None,
|
||||
q: str = "",
|
||||
include_inactive: bool = True,
|
||||
):
|
||||
fy = normalize_financial_year(financial_year)
|
||||
query = (
|
||||
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),
|
||||
selectinload(ClientServiceSubscription.due_date_rule),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.financial_year == fy,
|
||||
)
|
||||
)
|
||||
|
||||
if branch_id:
|
||||
query = query.where(ClientServiceSubscription.branch_id == branch_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(ClientServiceSubscription.is_active.is_(True))
|
||||
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = (
|
||||
query.join(Client, Client.id == ClientServiceSubscription.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
|
||||
.where(
|
||||
or_(
|
||||
Client.client_name.ilike(term),
|
||||
Client.client_code.ilike(term),
|
||||
ServiceCatalogue.service_code.ilike(term),
|
||||
ServiceCatalogue.service_name.ilike(term),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return db.execute(
|
||||
query.order_by(
|
||||
ClientServiceSubscription.is_locked.asc(),
|
||||
ClientServiceSubscription.is_active.desc(),
|
||||
ClientServiceSubscription.id.desc(),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_subscription(db: Session, *, subscription_id: int, tenant_id: int) -> ClientServiceSubscription | None:
|
||||
return db.execute(
|
||||
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),
|
||||
selectinload(ClientServiceSubscription.due_date_rule),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.id == subscription_id,
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_existing_subscription(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
service_catalogue_id: int,
|
||||
financial_year: str | None = None,
|
||||
) -> ClientServiceSubscription | None:
|
||||
return db.execute(
|
||||
select(ClientServiceSubscription).where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.client_id == client_id,
|
||||
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
|
||||
ClientServiceSubscription.financial_year == normalize_financial_year(financial_year),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_clients_for_assignment(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None):
|
||||
query = select(Client).where(Client.tenant_id == tenant_id)
|
||||
if branch_id:
|
||||
query = query.where(Client.branch_id == branch_id)
|
||||
if partner_id:
|
||||
query = query.where(Client.partner_id == partner_id)
|
||||
return db.execute(query.order_by(Client.client_name.asc())).scalars().all()
|
||||
|
||||
|
||||
def list_enabled_services_for_assignment(db: Session, *, tenant_id: int):
|
||||
return db.execute(
|
||||
select(FirmServiceSelection)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id)
|
||||
.options(selectinload(FirmServiceSelection.catalogue))
|
||||
.where(
|
||||
FirmServiceSelection.tenant_id == tenant_id,
|
||||
FirmServiceSelection.is_enabled.is_(True),
|
||||
ServiceCatalogue.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_enabled_firm_service(db: Session, *, tenant_id: int, service_catalogue_id: int) -> FirmServiceSelection | None:
|
||||
return db.execute(
|
||||
select(FirmServiceSelection).where(
|
||||
FirmServiceSelection.tenant_id == tenant_id,
|
||||
FirmServiceSelection.service_catalogue_id == service_catalogue_id,
|
||||
FirmServiceSelection.is_enabled.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_assignable_users(db: Session, *, tenant_id: int, branch_id: int | None = None, role_names: tuple[str, ...] = ASSIGNMENT_ROLE_NAMES):
|
||||
query = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(User.tenant_id == tenant_id, User.is_active.is_(True), Role.name.in_(role_names))
|
||||
)
|
||||
if branch_id:
|
||||
query = query.where(or_(User.branch_id == branch_id, User.branch_id.is_(None)))
|
||||
return db.execute(query.order_by(User.full_name.asc(), User.email.asc()).distinct()).scalars().all()
|
||||
|
||||
|
||||
def tenant_requires_review_partner(db: Session, *, tenant_id: int) -> bool:
|
||||
tenant = db.get(Tenant, tenant_id)
|
||||
firm_type = (getattr(tenant, "firm_type", None) or "partnership").strip().lower() if tenant else "partnership"
|
||||
return firm_type == "partnership"
|
||||
|
||||
|
||||
def review_partner_required_for_engagement(db: Session, *, tenant_id: int, engagement_type: str | None) -> bool:
|
||||
return tenant_requires_review_partner(db, tenant_id=tenant_id) and (engagement_type or "").strip().lower() == "assurance"
|
||||
|
||||
|
||||
def list_review_partners(db: Session, *, tenant_id: int, branch_id: int | None = None):
|
||||
return list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",))
|
||||
@@ -0,0 +1,341 @@
|
||||
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.services.models import ClientServiceSubscription
|
||||
from app.modules.services.client_services import (
|
||||
SUBSCRIPTION_STATUSES,
|
||||
get_enabled_firm_service,
|
||||
get_existing_subscription,
|
||||
get_subscription,
|
||||
list_assignable_users,
|
||||
list_clients_for_assignment,
|
||||
list_enabled_services_for_assignment,
|
||||
list_subscription_payload,
|
||||
parse_date,
|
||||
)
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/services/client-services", tags=["services-client-services-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **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),
|
||||
"subscription_statuses": SUBSCRIPTION_STATUSES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(
|
||||
request.session.get("active_tenant_id")
|
||||
or request.session.get("selected_tenant_id")
|
||||
or request.session.get("tenant_id")
|
||||
or user.tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "clients.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _locked_partner_id(db, user) -> int | None:
|
||||
return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None
|
||||
|
||||
|
||||
def _can_manage_client_services(db, user) -> bool:
|
||||
return _has_perm(db, user, "clients.edit")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def subscription_list(request: Request, q: str = "", include_inactive: bool = True):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
rows = list_subscription_payload(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
q=q,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/client_services/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Client Service Subscriptions",
|
||||
rows=rows,
|
||||
q=q,
|
||||
include_inactive=include_inactive,
|
||||
can_manage=_can_manage_client_services(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/new")
|
||||
def subscription_create_page(request: Request, client_id: int | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
clients = list_clients_for_assignment(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
)
|
||||
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||
assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/client_services/form.html",
|
||||
db,
|
||||
user,
|
||||
title="Assign Service to Client",
|
||||
mode="create",
|
||||
subscription=None,
|
||||
clients=clients,
|
||||
enabled_services=enabled_services,
|
||||
assignable_users=assignable_users,
|
||||
selected_client_id=client_id,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
def subscription_create_submit(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
service_catalogue_id: int = Form(...),
|
||||
assigned_partner_user_id: str = Form(""),
|
||||
assigned_manager_user_id: str = Form(""),
|
||||
assigned_staff_user_id: str = Form(""),
|
||||
start_date: str = Form(""),
|
||||
end_date: str = Form(""),
|
||||
status: str = Form("active"),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
|
||||
if not firm_selection:
|
||||
return RedirectResponse(url="/services/client-services/new", status_code=303)
|
||||
|
||||
existing = get_existing_subscription(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
)
|
||||
if existing:
|
||||
row = existing
|
||||
else:
|
||||
row = ClientServiceSubscription(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
row.branch_id = firm_selection.default_branch_id or getattr(user, "branch_id", None)
|
||||
row.firm_service_selection_id = firm_selection.id
|
||||
row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None
|
||||
row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None
|
||||
row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None
|
||||
row.start_date = parse_date(start_date)
|
||||
row.end_date = parse_date(end_date)
|
||||
row.status = status or "active"
|
||||
row.remarks = remarks.strip() or None
|
||||
row.is_active = is_active is not None
|
||||
row.updated_by_user_id = user.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return RedirectResponse(url=f"/services/client-services/{row.id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{subscription_id}")
|
||||
def subscription_detail(request: Request, subscription_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row:
|
||||
return RedirectResponse(url="/services/client-services", status_code=303)
|
||||
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/client_services/detail.html",
|
||||
db,
|
||||
user,
|
||||
title="Client Service Subscription",
|
||||
row=row,
|
||||
can_manage=_can_manage_client_services(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{subscription_id}/edit")
|
||||
def subscription_edit_page(request: Request, subscription_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row:
|
||||
return RedirectResponse(url="/services/client-services", status_code=303)
|
||||
|
||||
clients = list_clients_for_assignment(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
)
|
||||
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||
assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/client_services/form.html",
|
||||
db,
|
||||
user,
|
||||
title="Edit Client Service Subscription",
|
||||
mode="edit",
|
||||
subscription=row,
|
||||
clients=clients,
|
||||
enabled_services=enabled_services,
|
||||
assignable_users=assignable_users,
|
||||
selected_client_id=row.client_id,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/edit")
|
||||
def subscription_edit_submit(
|
||||
request: Request,
|
||||
subscription_id: int,
|
||||
assigned_partner_user_id: str = Form(""),
|
||||
assigned_manager_user_id: str = Form(""),
|
||||
assigned_staff_user_id: str = Form(""),
|
||||
start_date: str = Form(""),
|
||||
end_date: str = Form(""),
|
||||
status: str = Form("active"),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row:
|
||||
return RedirectResponse(url="/services/client-services", status_code=303)
|
||||
|
||||
row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None
|
||||
row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None
|
||||
row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None
|
||||
row.start_date = parse_date(start_date)
|
||||
row.end_date = parse_date(end_date)
|
||||
row.status = status or "active"
|
||||
row.remarks = remarks.strip() or None
|
||||
row.is_active = is_active is not None
|
||||
row.updated_by_user_id = user.id
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/client-services/{row.id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ServiceCatalogue,
|
||||
ServiceDueDateExtension,
|
||||
ServiceDueDateRule,
|
||||
)
|
||||
|
||||
DUE_PERIOD_TYPES = [
|
||||
("yearly", "Yearly / Annual"),
|
||||
("monthly", "Monthly"),
|
||||
("quarterly", "Quarterly"),
|
||||
("one_time", "One Time"),
|
||||
("event_based", "Event Based"),
|
||||
("renewal_based", "Renewal Before Expiry"),
|
||||
("custom", "Custom / Manual"),
|
||||
]
|
||||
|
||||
DUE_YEAR_BASIS_CHOICES = [
|
||||
("assessment_year_start", "Assessment year start year"),
|
||||
("financial_year_start", "Financial year start year"),
|
||||
("financial_year_end", "Financial year end year"),
|
||||
("calendar_year", "Calendar year from period"),
|
||||
]
|
||||
|
||||
DUE_DATE_SOURCE_RULE = "rule"
|
||||
DUE_DATE_SOURCE_EXTENSION = "extension"
|
||||
DUE_DATE_SOURCE_MANUAL = "manual"
|
||||
|
||||
|
||||
def parse_optional_date(value: str | None) -> date | None:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return date.fromisoformat(value)
|
||||
|
||||
|
||||
def _parse_year_pair(value: str | None) -> tuple[int, int] | None:
|
||||
value = (value or "").strip()
|
||||
match = re.match(r"^(\d{4})\s*-\s*(\d{2}|\d{4})$", value)
|
||||
if not match:
|
||||
return None
|
||||
start = int(match.group(1))
|
||||
end_raw = match.group(2)
|
||||
end = int(end_raw) if len(end_raw) == 4 else int(str(start)[:2] + end_raw)
|
||||
return start, end
|
||||
|
||||
|
||||
def _month_add(year: int, month: int, offset: int) -> tuple[int, int]:
|
||||
index = (year * 12 + (month - 1)) + int(offset or 0)
|
||||
return index // 12, index % 12 + 1
|
||||
|
||||
|
||||
def _safe_date(year: int, month: int, day: int) -> date | None:
|
||||
try:
|
||||
return date(int(year), int(month), int(day))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _period_month_from_label(financial_year: str | None, period_label: str | None) -> tuple[int, int] | None:
|
||||
label = (period_label or "").strip().lower()
|
||||
fy = _parse_year_pair(financial_year)
|
||||
|
||||
iso_match = re.match(r"^(\d{4})[-/](\d{1,2})$", label)
|
||||
if iso_match:
|
||||
return int(iso_match.group(1)), int(iso_match.group(2))
|
||||
|
||||
month_names = {
|
||||
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
|
||||
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10, "october": 10,
|
||||
"nov": 11, "november": 11, "dec": 12, "december": 12, "jan": 1, "january": 1,
|
||||
"feb": 2, "february": 2, "mar": 3, "march": 3,
|
||||
}
|
||||
if label in month_names and fy:
|
||||
month = month_names[label]
|
||||
year = fy[0] if month >= 4 else fy[1]
|
||||
return year, month
|
||||
return None
|
||||
|
||||
|
||||
def _quarter_end_from_label(financial_year: str | None, period_label: str | None) -> tuple[int, int] | None:
|
||||
label = (period_label or "").strip().lower().replace(" ", "")
|
||||
fy = _parse_year_pair(financial_year)
|
||||
if not fy:
|
||||
return None
|
||||
mapping = {
|
||||
"q1": (fy[0], 6), "quarter1": (fy[0], 6), "apr-jun": (fy[0], 6),
|
||||
"q2": (fy[0], 9), "quarter2": (fy[0], 9), "jul-sep": (fy[0], 9),
|
||||
"q3": (fy[0], 12), "quarter3": (fy[0], 12), "oct-dec": (fy[0], 12),
|
||||
"q4": (fy[1], 3), "quarter4": (fy[1], 3), "jan-mar": (fy[1], 3),
|
||||
}
|
||||
return mapping.get(label)
|
||||
|
||||
|
||||
def calculate_due_date(
|
||||
rule: ServiceDueDateRule | None,
|
||||
*,
|
||||
financial_year: str | None,
|
||||
assessment_year: str | None = None,
|
||||
period_label: str | None = None,
|
||||
expiry_date: date | None = None,
|
||||
) -> date | None:
|
||||
"""Calculate a statutory due date from a catalogue due-date rule.
|
||||
|
||||
The function is intentionally conservative. If the rule needs a period label
|
||||
and the engagement does not yet carry one, it returns None instead of
|
||||
guessing. This preserves existing engagement creation behaviour.
|
||||
"""
|
||||
if not rule or not getattr(rule, "is_active", True):
|
||||
return None
|
||||
day = getattr(rule, "due_day", None)
|
||||
|
||||
period_type = (getattr(rule, "period_type", None) or "yearly").strip().lower()
|
||||
due_month = getattr(rule, "due_month", None)
|
||||
month_offset = int(getattr(rule, "due_month_offset", None) or 0)
|
||||
|
||||
if period_type in {"renewal_based", "before_expiry", "expiry_based"}:
|
||||
if not expiry_date:
|
||||
return None
|
||||
days_before = int(getattr(rule, "renewal_days_before_expiry", None) or 0)
|
||||
from datetime import timedelta
|
||||
return expiry_date - timedelta(days=days_before)
|
||||
|
||||
if not day:
|
||||
return None
|
||||
|
||||
if period_type in {"yearly", "one_time"}:
|
||||
if not due_month:
|
||||
return None
|
||||
basis = (getattr(rule, "due_year_basis", None) or "assessment_year_start").strip().lower()
|
||||
fy = _parse_year_pair(financial_year)
|
||||
ay = _parse_year_pair(assessment_year)
|
||||
if basis == "financial_year_start" and fy:
|
||||
year = fy[0]
|
||||
elif basis == "financial_year_end" and fy:
|
||||
year = fy[1]
|
||||
elif ay:
|
||||
year = ay[0]
|
||||
elif fy:
|
||||
year = fy[1]
|
||||
else:
|
||||
return None
|
||||
return _safe_date(year, int(due_month), int(day))
|
||||
|
||||
if period_type == "monthly":
|
||||
period = _period_month_from_label(financial_year, period_label)
|
||||
if not period:
|
||||
return None
|
||||
year, month = _month_add(period[0], period[1], month_offset)
|
||||
return _safe_date(year, month, int(day))
|
||||
|
||||
if period_type == "quarterly":
|
||||
period = _quarter_end_from_label(financial_year, period_label)
|
||||
if not period:
|
||||
return None
|
||||
year, month = _month_add(period[0], period[1], month_offset)
|
||||
return _safe_date(year, month, int(day))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_active_due_rule_for_catalogue(db: Session, *, catalogue_id: int) -> ServiceDueDateRule | None:
|
||||
return db.execute(
|
||||
select(ServiceDueDateRule)
|
||||
.where(
|
||||
ServiceDueDateRule.service_catalogue_id == catalogue_id,
|
||||
ServiceDueDateRule.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc())
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def list_due_rules(db: Session, *, catalogue_id: int, include_inactive: bool = True) -> list[ServiceDueDateRule]:
|
||||
query = select(ServiceDueDateRule).where(ServiceDueDateRule.service_catalogue_id == catalogue_id)
|
||||
if not include_inactive:
|
||||
query = query.where(ServiceDueDateRule.is_active.is_(True))
|
||||
return db.execute(query.order_by(ServiceDueDateRule.sort_order.asc(), ServiceDueDateRule.id.asc())).scalars().all()
|
||||
|
||||
|
||||
def get_due_rule(db: Session, *, rule_id: int, catalogue_id: int | None = None) -> ServiceDueDateRule | None:
|
||||
query = select(ServiceDueDateRule).where(ServiceDueDateRule.id == rule_id)
|
||||
if catalogue_id:
|
||||
query = query.where(ServiceDueDateRule.service_catalogue_id == catalogue_id)
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_due_extensions(db: Session, *, catalogue_id: int, tenant_id: int | None = None, limit: int = 20) -> list[ServiceDueDateExtension]:
|
||||
query = (
|
||||
select(ServiceDueDateExtension)
|
||||
.options(selectinload(ServiceDueDateExtension.due_rule))
|
||||
.where(ServiceDueDateExtension.service_catalogue_id == catalogue_id)
|
||||
)
|
||||
if tenant_id:
|
||||
query = query.where(ServiceDueDateExtension.tenant_id == tenant_id)
|
||||
return db.execute(
|
||||
query.order_by(ServiceDueDateExtension.id.desc()).limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def apply_due_date_rule_to_subscription(
|
||||
db: Session,
|
||||
subscription: ClientServiceSubscription,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> date | None:
|
||||
if getattr(subscription, "is_locked", False):
|
||||
return getattr(subscription, "current_due_date", None)
|
||||
rule = get_active_due_rule_for_catalogue(db, catalogue_id=subscription.service_catalogue_id)
|
||||
rule_type = (getattr(rule, "period_type", None) or "").strip().lower() if rule else ""
|
||||
# For normal statutory rules, preserve an already calculated/extended due date.
|
||||
# For renewal-based rules, recalculate when expiry_date changes, unless the due date was manually overridden/extended.
|
||||
if getattr(subscription, "current_due_date", None) and not force:
|
||||
if rule_type not in {"renewal_based", "before_expiry", "expiry_based"}:
|
||||
return subscription.current_due_date
|
||||
if getattr(subscription, "due_date_source", None) in {DUE_DATE_SOURCE_EXTENSION, DUE_DATE_SOURCE_MANUAL}:
|
||||
return subscription.current_due_date
|
||||
calculated = calculate_due_date(
|
||||
rule,
|
||||
financial_year=subscription.financial_year,
|
||||
assessment_year=subscription.assessment_year,
|
||||
period_label=getattr(subscription, "period_label", None),
|
||||
expiry_date=getattr(subscription, "expiry_date", None),
|
||||
)
|
||||
if calculated:
|
||||
subscription.due_date_rule_id = rule.id if rule else None
|
||||
subscription.original_due_date = calculated
|
||||
subscription.current_due_date = calculated
|
||||
subscription.due_date_source = DUE_DATE_SOURCE_RULE
|
||||
return calculated
|
||||
|
||||
|
||||
def _matching_extension_query(
|
||||
*,
|
||||
tenant_id: int,
|
||||
catalogue_id: int,
|
||||
rule_id: int | None,
|
||||
financial_year: str,
|
||||
assessment_year: str | None,
|
||||
period_label: str | None,
|
||||
):
|
||||
query = select(ServiceDueDateExtension).where(
|
||||
ServiceDueDateExtension.tenant_id == tenant_id,
|
||||
ServiceDueDateExtension.service_catalogue_id == catalogue_id,
|
||||
ServiceDueDateExtension.financial_year == financial_year,
|
||||
)
|
||||
if rule_id:
|
||||
query = query.where(ServiceDueDateExtension.due_date_rule_id == rule_id)
|
||||
if assessment_year:
|
||||
query = query.where(ServiceDueDateExtension.assessment_year == assessment_year)
|
||||
if period_label:
|
||||
query = query.where(ServiceDueDateExtension.period_label == period_label)
|
||||
else:
|
||||
query = query.where((ServiceDueDateExtension.period_label.is_(None)) | (ServiceDueDateExtension.period_label == ""))
|
||||
return query
|
||||
|
||||
|
||||
def create_due_date_extension(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
catalogue_id: int,
|
||||
due_date_rule_id: int | None,
|
||||
financial_year: str,
|
||||
assessment_year: str | None,
|
||||
period_label: str | None,
|
||||
extended_due_date: date,
|
||||
notification_reference: str | None,
|
||||
notification_date: date | None,
|
||||
remarks: str | None,
|
||||
user_id: int,
|
||||
) -> tuple[ServiceDueDateExtension, int, int]:
|
||||
rule = get_due_rule(db, rule_id=due_date_rule_id, catalogue_id=catalogue_id) if due_date_rule_id else get_active_due_rule_for_catalogue(db, catalogue_id=catalogue_id)
|
||||
latest = db.execute(
|
||||
_matching_extension_query(
|
||||
tenant_id=tenant_id,
|
||||
catalogue_id=catalogue_id,
|
||||
rule_id=rule.id if rule else None,
|
||||
financial_year=financial_year,
|
||||
assessment_year=assessment_year,
|
||||
period_label=period_label,
|
||||
).order_by(ServiceDueDateExtension.extension_sequence.desc(), ServiceDueDateExtension.id.desc())
|
||||
).scalars().first()
|
||||
|
||||
base_due = calculate_due_date(rule, financial_year=financial_year, assessment_year=assessment_year, period_label=period_label)
|
||||
previous_due = latest.extended_due_date if latest else base_due
|
||||
sequence = int((latest.extension_sequence if latest else 0) or 0) + 1
|
||||
|
||||
extension = ServiceDueDateExtension(
|
||||
tenant_id=tenant_id,
|
||||
service_catalogue_id=catalogue_id,
|
||||
due_date_rule_id=rule.id if rule else None,
|
||||
financial_year=financial_year,
|
||||
assessment_year=assessment_year,
|
||||
period_label=(period_label or "").strip() or None,
|
||||
previous_due_date=previous_due,
|
||||
extended_due_date=extended_due_date,
|
||||
extension_sequence=sequence,
|
||||
notification_reference=(notification_reference or "").strip() or None,
|
||||
notification_date=notification_date,
|
||||
remarks=(remarks or "").strip() or None,
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
)
|
||||
db.add(extension)
|
||||
db.flush()
|
||||
|
||||
updated = skipped_locked = 0
|
||||
sub_query = select(ClientServiceSubscription).where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.service_catalogue_id == catalogue_id,
|
||||
ClientServiceSubscription.financial_year == financial_year,
|
||||
)
|
||||
if assessment_year:
|
||||
sub_query = sub_query.where(ClientServiceSubscription.assessment_year == assessment_year)
|
||||
subscriptions = db.execute(sub_query).scalars().all()
|
||||
for sub in subscriptions:
|
||||
if getattr(sub, "is_locked", False):
|
||||
skipped_locked += 1
|
||||
continue
|
||||
if rule:
|
||||
sub.due_date_rule_id = rule.id
|
||||
if not getattr(sub, "original_due_date", None):
|
||||
sub.original_due_date = previous_due or base_due
|
||||
sub.current_due_date = extended_due_date
|
||||
sub.due_date_source = DUE_DATE_SOURCE_EXTENSION
|
||||
sub.updated_by_user_id = user_id
|
||||
updated += 1
|
||||
return extension, updated, skipped_locked
|
||||
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
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.services.models import ClientServiceSubscription, ClientServiceTaskInstance
|
||||
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.services.client_services import (
|
||||
SUBSCRIPTION_STATUSES,
|
||||
assessment_year_from_financial_year,
|
||||
current_financial_year,
|
||||
get_enabled_firm_service,
|
||||
get_existing_subscription,
|
||||
get_subscription,
|
||||
normalize_financial_year,
|
||||
list_assignable_users,
|
||||
list_clients_for_assignment,
|
||||
list_enabled_services_for_assignment,
|
||||
list_subscription_payload,
|
||||
list_review_partners,
|
||||
parse_date,
|
||||
review_partner_required_for_engagement,
|
||||
)
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
|
||||
|
||||
router = APIRouter(prefix="/services/engagements", tags=["services-engagements-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **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),
|
||||
"subscription_statuses": SUBSCRIPTION_STATUSES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(
|
||||
request.session.get("active_tenant_id")
|
||||
or request.session.get("selected_tenant_id")
|
||||
or request.session.get("tenant_id")
|
||||
or user.tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "clients.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _active_financial_year(request: Request) -> str:
|
||||
return normalize_financial_year(
|
||||
request.session.get("active_financial_year")
|
||||
or getattr(request.state, "year_code", None)
|
||||
)
|
||||
|
||||
|
||||
def _locked_partner_id(db, user) -> int | None:
|
||||
return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None
|
||||
|
||||
|
||||
def _can_manage_client_services(db, user) -> bool:
|
||||
return _has_perm(db, user, "clients.edit")
|
||||
|
||||
|
||||
def _can_lock_engagements(db, user) -> bool:
|
||||
roles = set(get_user_roles(db, user.id))
|
||||
return bool(roles.intersection({"Firm Admin", "Partner"}))
|
||||
|
||||
|
||||
def _user_can_lock_subscription(db, user, row: ClientServiceSubscription) -> bool:
|
||||
roles = set(get_user_roles(db, user.id))
|
||||
if "Firm Admin" in roles:
|
||||
return True
|
||||
if "Partner" in roles:
|
||||
client = getattr(row, "client", None)
|
||||
return (
|
||||
getattr(row, "assigned_partner_user_id", None) == user.id
|
||||
or getattr(client, "partner_id", None) == user.id
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _lock_subscription_row(row: ClientServiceSubscription, user) -> bool:
|
||||
if getattr(row, "is_locked", False):
|
||||
return False
|
||||
from datetime import datetime, timezone
|
||||
|
||||
row.is_locked = True
|
||||
row.status = "completed" if row.status == "active" else row.status
|
||||
row.locked_at_utc = datetime.now(timezone.utc)
|
||||
row.locked_by_user_id = user.id
|
||||
row.updated_by_user_id = user.id
|
||||
return True
|
||||
|
||||
|
||||
@router.get("")
|
||||
def subscription_list(request: Request, q: str = "", financial_year: str = "", include_inactive: bool = True, locked: int = 0, skipped: int = 0):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
|
||||
rows = list_subscription_payload(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
financial_year=selected_financial_year,
|
||||
q=q,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/engagements/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Engagement Subscriptions",
|
||||
rows=rows,
|
||||
q=q,
|
||||
financial_year=selected_financial_year,
|
||||
include_inactive=include_inactive,
|
||||
locked_count=locked,
|
||||
skipped_count=skipped,
|
||||
can_manage=_can_manage_client_services(db, user),
|
||||
can_lock_engagements=_can_lock_engagements(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/new")
|
||||
def subscription_create_page(request: Request, client_id: int | None = None):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
clients = list_clients_for_assignment(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
)
|
||||
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||
assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/engagements/form.html",
|
||||
db,
|
||||
user,
|
||||
title="Assign Service to Client",
|
||||
mode="create",
|
||||
subscription=None,
|
||||
clients=clients,
|
||||
enabled_services=enabled_services,
|
||||
assignable_users=assignable_users,
|
||||
review_partners=review_partners,
|
||||
selected_client_id=client_id,
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
def subscription_create_submit(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
service_catalogue_id: int = Form(...),
|
||||
assigned_partner_user_id: str = Form(""),
|
||||
assigned_manager_user_id: str = Form(""),
|
||||
assigned_staff_user_id: str = Form(""),
|
||||
review_partner_user_id: str = Form(""),
|
||||
financial_year: str = Form(""),
|
||||
start_date: str = Form(""),
|
||||
end_date: str = Form(""),
|
||||
expiry_date: str = Form(""),
|
||||
status: str = Form("active"),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=selected_financial_year, redirect_url=f"/services/engagements?financial_year={selected_financial_year}")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
|
||||
if not firm_selection:
|
||||
return RedirectResponse(url="/services/engagements/new", status_code=303)
|
||||
|
||||
existing = get_existing_subscription(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
financial_year=selected_financial_year,
|
||||
)
|
||||
client = db.get(Client, client_id)
|
||||
if not client or client.tenant_id != tenant_id:
|
||||
return RedirectResponse(url="/services/engagements/new", status_code=303)
|
||||
|
||||
if existing:
|
||||
row = existing
|
||||
else:
|
||||
row = ClientServiceSubscription(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
service_catalogue_id=service_catalogue_id,
|
||||
financial_year=selected_financial_year,
|
||||
assessment_year=assessment_year_from_financial_year(selected_financial_year),
|
||||
engagement_type=getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance",
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row):
|
||||
return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303)
|
||||
|
||||
row.financial_year = selected_financial_year
|
||||
row.assessment_year = assessment_year_from_financial_year(selected_financial_year)
|
||||
if not getattr(row, "engagement_type", None):
|
||||
row.engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance"
|
||||
row.branch_id = firm_selection.default_branch_id or getattr(user, "branch_id", None)
|
||||
row.firm_service_selection_id = firm_selection.id
|
||||
row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None
|
||||
row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None
|
||||
row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None
|
||||
if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=row.engagement_type):
|
||||
row.review_partner_user_id = int(review_partner_user_id) if review_partner_user_id.strip() else getattr(client, "default_review_partner_user_id", None)
|
||||
else:
|
||||
row.review_partner_user_id = None
|
||||
row.start_date = parse_date(start_date)
|
||||
row.end_date = parse_date(end_date)
|
||||
row.expiry_date = parse_date(expiry_date)
|
||||
row.status = status or "active"
|
||||
row.remarks = remarks.strip() or None
|
||||
row.is_active = is_active is not None
|
||||
row.updated_by_user_id = user.id
|
||||
apply_due_date_rule_to_subscription(db, row)
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/bulk-lock")
|
||||
def subscription_bulk_lock(
|
||||
request: Request,
|
||||
subscription_ids: list[int] = Form([]),
|
||||
financial_year: str = Form(""),
|
||||
q: str = Form(""),
|
||||
include_inactive: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
locked_count = 0
|
||||
skipped_count = 0
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not _can_lock_engagements(db, user):
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
selected_ids = [int(value) for value in subscription_ids if value]
|
||||
for subscription_id in selected_ids:
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row or not _user_can_lock_subscription(db, user, row):
|
||||
skipped_count += 1
|
||||
continue
|
||||
if _lock_subscription_row(row, user):
|
||||
locked_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
if locked_count:
|
||||
db.commit()
|
||||
|
||||
params = []
|
||||
fy = normalize_financial_year(financial_year or _active_financial_year(request))
|
||||
if fy:
|
||||
params.append(f"financial_year={fy}")
|
||||
if q.strip():
|
||||
from urllib.parse import quote_plus
|
||||
params.append(f"q={quote_plus(q.strip())}")
|
||||
if include_inactive:
|
||||
params.append("include_inactive=true")
|
||||
params.append(f"locked={locked_count}")
|
||||
params.append(f"skipped={skipped_count}")
|
||||
suffix = "?" + "&".join(params) if params else ""
|
||||
return RedirectResponse(url=f"/services/engagements{suffix}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{subscription_id}")
|
||||
def subscription_detail(request: Request, subscription_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row:
|
||||
return RedirectResponse(url="/services/engagements", status_code=303)
|
||||
active_fy = _active_financial_year(request)
|
||||
if row.financial_year != active_fy:
|
||||
return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303)
|
||||
|
||||
tasks = db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.where(ClientServiceTaskInstance.subscription_id == row.id)
|
||||
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/engagements/detail.html",
|
||||
db,
|
||||
user,
|
||||
title="Engagement Subscription",
|
||||
row=row,
|
||||
tasks=tasks,
|
||||
can_manage=_can_manage_client_services(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{subscription_id}/edit")
|
||||
def subscription_edit_page(request: Request, subscription_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row:
|
||||
return RedirectResponse(url="/services/engagements", status_code=303)
|
||||
active_fy = _active_financial_year(request)
|
||||
if row.financial_year != active_fy:
|
||||
return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303)
|
||||
if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row):
|
||||
return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303)
|
||||
|
||||
clients = list_clients_for_assignment(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
)
|
||||
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||
assignable_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/engagements/form.html",
|
||||
db,
|
||||
user,
|
||||
title="Edit Engagement Subscription",
|
||||
mode="edit",
|
||||
subscription=row,
|
||||
clients=clients,
|
||||
enabled_services=enabled_services,
|
||||
assignable_users=assignable_users,
|
||||
review_partners=review_partners,
|
||||
selected_client_id=row.client_id,
|
||||
financial_year=row.financial_year,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/edit")
|
||||
def subscription_edit_submit(
|
||||
request: Request,
|
||||
subscription_id: int,
|
||||
assigned_partner_user_id: str = Form(""),
|
||||
assigned_manager_user_id: str = Form(""),
|
||||
assigned_staff_user_id: str = Form(""),
|
||||
review_partner_user_id: str = Form(""),
|
||||
start_date: str = Form(""),
|
||||
end_date: str = Form(""),
|
||||
expiry_date: str = Form(""),
|
||||
status: str = Form("active"),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if not row:
|
||||
return RedirectResponse(url="/services/engagements", status_code=303)
|
||||
active_fy = _active_financial_year(request)
|
||||
if row.financial_year != active_fy:
|
||||
return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303)
|
||||
if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row):
|
||||
return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303)
|
||||
|
||||
row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None
|
||||
row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None
|
||||
row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None
|
||||
if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=row.engagement_type):
|
||||
row.review_partner_user_id = int(review_partner_user_id) if review_partner_user_id.strip() else getattr(row.client, "default_review_partner_user_id", None)
|
||||
else:
|
||||
row.review_partner_user_id = None
|
||||
row.start_date = parse_date(start_date)
|
||||
row.end_date = parse_date(end_date)
|
||||
row.expiry_date = parse_date(expiry_date)
|
||||
row.status = status or "active"
|
||||
row.remarks = remarks.strip() or None
|
||||
row.is_active = is_active is not None
|
||||
row.updated_by_user_id = user.id
|
||||
apply_due_date_rule_to_subscription(db, row)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/lock")
|
||||
def subscription_lock(request: Request, subscription_id: int, csrf_token: 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)
|
||||
if not _can_lock_engagements(db, user):
|
||||
return _redirect_denied()
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
|
||||
if row and row.financial_year != _active_financial_year(request):
|
||||
return RedirectResponse(url=f"/services/engagements?financial_year={_active_financial_year(request)}", status_code=303)
|
||||
if row and is_row_financial_year_locked(db, row):
|
||||
return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303)
|
||||
if row and _user_can_lock_subscription(db, user, row):
|
||||
if _lock_subscription_row(row, user):
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,609 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceTaskComment,
|
||||
FirmServiceTaskTemplate,
|
||||
ServiceCatalogue,
|
||||
)
|
||||
|
||||
TASK_STATUSES = [
|
||||
("pending", "Pending"),
|
||||
("in_progress", "In Progress"),
|
||||
("completed", "Completed"),
|
||||
("blocked", "Blocked"),
|
||||
("not_applicable", "Not Applicable"),
|
||||
("cancelled", "Cancelled"),
|
||||
]
|
||||
|
||||
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked"}
|
||||
CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"}
|
||||
|
||||
|
||||
TASK_COMMENT_TYPES = [
|
||||
("internal_note", "Internal Note"),
|
||||
("client_clarification", "Client Clarification"),
|
||||
("consultant_clarification", "Consultant Clarification"),
|
||||
("partner_review_note", "Partner Review Note"),
|
||||
]
|
||||
|
||||
TASK_COMMENT_VISIBILITIES = [
|
||||
("internal", "Internal"),
|
||||
("client", "Client"),
|
||||
("consultant", "Consultant"),
|
||||
]
|
||||
|
||||
TASK_PRIORITIES = [
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
]
|
||||
|
||||
|
||||
def _normalise_status(status: str | None) -> str:
|
||||
allowed = {code for code, _label in TASK_STATUSES}
|
||||
value = (status or "pending").strip().lower()
|
||||
return value if value in allowed else "pending"
|
||||
|
||||
|
||||
def _normalise_priority(priority: str | None) -> str:
|
||||
allowed = {code for code, _label in TASK_PRIORITIES}
|
||||
value = (priority or "normal").strip().lower()
|
||||
return value if value in allowed else "normal"
|
||||
|
||||
|
||||
def _normalise_comment_type(comment_type: str | None) -> str:
|
||||
allowed = {code for code, _label in TASK_COMMENT_TYPES}
|
||||
value = (comment_type or "internal_note").strip().lower()
|
||||
return value if value in allowed else "internal_note"
|
||||
|
||||
|
||||
def _normalise_visibility(visibility: str | None) -> str:
|
||||
allowed = {code for code, _label in TASK_COMMENT_VISIBILITIES}
|
||||
value = (visibility or "internal").strip().lower()
|
||||
return value if value in allowed else "internal"
|
||||
|
||||
|
||||
def parse_date_value(value: str | None) -> date | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return date.fromisoformat(text)
|
||||
|
||||
|
||||
def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None:
|
||||
role = (template.default_role_name or "").strip().lower()
|
||||
if "partner" in role:
|
||||
return subscription.assigned_partner_user_id
|
||||
if "manager" in role:
|
||||
return subscription.assigned_manager_user_id
|
||||
if "staff" in role or "employee" in role:
|
||||
return subscription.assigned_staff_user_id
|
||||
return subscription.assigned_staff_user_id or subscription.assigned_manager_user_id or subscription.assigned_partner_user_id
|
||||
|
||||
|
||||
def get_subscription_for_execution(db: Session, *, tenant_id: int, subscription_id: int) -> ClientServiceSubscription | None:
|
||||
return db.execute(
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.client),
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.id == subscription_id,
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_subscription_execution_payload(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None, q: str = ""):
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.client),
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
ClientServiceSubscription.status == "active",
|
||||
ClientServiceSubscription.is_locked.is_(False),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
if branch_id:
|
||||
query = query.where(ClientServiceSubscription.branch_id == branch_id)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = (
|
||||
query.join(Client, Client.id == ClientServiceSubscription.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
|
||||
.where(
|
||||
or_(
|
||||
Client.client_name.ilike(term),
|
||||
Client.client_code.ilike(term),
|
||||
ServiceCatalogue.service_code.ilike(term),
|
||||
ServiceCatalogue.service_name.ilike(term),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
rows = db.execute(query.order_by(ClientServiceSubscription.id.desc())).scalars().all()
|
||||
payload = []
|
||||
for sub in rows:
|
||||
total = db.execute(
|
||||
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||
ClientServiceTaskInstance.subscription_id == sub.id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).scalar_one()
|
||||
completed = db.execute(
|
||||
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||
ClientServiceTaskInstance.subscription_id == sub.id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
ClientServiceTaskInstance.status == "completed",
|
||||
)
|
||||
).scalar_one()
|
||||
open_tasks = db.execute(
|
||||
select(func.count(ClientServiceTaskInstance.id)).where(
|
||||
ClientServiceTaskInstance.subscription_id == sub.id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
ClientServiceTaskInstance.status.in_(list(OPEN_TASK_STATUSES)),
|
||||
)
|
||||
).scalar_one()
|
||||
payload.append({"subscription": sub, "total_tasks": total, "completed_tasks": completed, "open_tasks": open_tasks})
|
||||
return payload
|
||||
|
||||
|
||||
def _default_internal_target_date(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> date | None:
|
||||
# Phase 4B keeps task target dates internal. Existing task templates do not yet have
|
||||
# an offset field, so new generated tasks start blank and can be assigned through the tracker.
|
||||
return None
|
||||
|
||||
|
||||
def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceSubscription, user_id: int) -> int:
|
||||
templates = db.execute(
|
||||
select(FirmServiceTaskTemplate)
|
||||
.where(
|
||||
FirmServiceTaskTemplate.tenant_id == subscription.tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == subscription.service_catalogue_id,
|
||||
FirmServiceTaskTemplate.is_active.is_(True),
|
||||
)
|
||||
.order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
created = 0
|
||||
for template in templates:
|
||||
existing = db.execute(
|
||||
select(ClientServiceTaskInstance.id).where(
|
||||
ClientServiceTaskInstance.subscription_id == subscription.id,
|
||||
ClientServiceTaskInstance.firm_task_template_id == template.id,
|
||||
ClientServiceTaskInstance.financial_year == subscription.financial_year,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
db.add(
|
||||
ClientServiceTaskInstance(
|
||||
tenant_id=subscription.tenant_id,
|
||||
branch_id=subscription.branch_id,
|
||||
subscription_id=subscription.id,
|
||||
client_id=subscription.client_id,
|
||||
service_catalogue_id=subscription.service_catalogue_id,
|
||||
firm_task_template_id=template.id,
|
||||
financial_year=subscription.financial_year,
|
||||
assessment_year=subscription.assessment_year,
|
||||
task_name=template.task_name,
|
||||
description=template.description,
|
||||
sequence_no=template.sequence_no,
|
||||
default_role_name=template.default_role_name,
|
||||
assigned_to_user_id=_default_assignee_for_template(subscription, template),
|
||||
internal_target_date=_default_internal_target_date(subscription, template),
|
||||
status="pending",
|
||||
priority="normal",
|
||||
is_active=True,
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
return created
|
||||
|
||||
|
||||
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
|
||||
target_date = getattr(task, "internal_target_date", None)
|
||||
subscription = getattr(task, "subscription", None)
|
||||
engagement_due_date = getattr(subscription, "current_due_date", None) if subscription else None
|
||||
task.is_task_overdue = bool(target_date and target_date < today and task.status not in CLOSED_TASK_STATUSES)
|
||||
task.is_due_today = bool(target_date and target_date == today and task.status not in CLOSED_TASK_STATUSES)
|
||||
task.is_engagement_due_overdue = bool(
|
||||
engagement_due_date and engagement_due_date < today and task.status not in CLOSED_TASK_STATUSES
|
||||
)
|
||||
task.tracker_status_label = dict(TASK_STATUSES).get(task.status, task.status)
|
||||
task.priority_label = dict(TASK_PRIORITIES).get(task.priority, task.priority)
|
||||
return task
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_partner_visibility_filter(query, partner_user_id: int | None):
|
||||
if not partner_user_id:
|
||||
return query
|
||||
return query.where(
|
||||
or_(
|
||||
ClientServiceTaskInstance.subscription.has(
|
||||
ClientServiceSubscription.assigned_partner_user_id == partner_user_id
|
||||
),
|
||||
ClientServiceTaskInstance.client.has(Client.partner_id == partner_user_id),
|
||||
)
|
||||
)
|
||||
|
||||
def list_tasks_payload(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
assigned_to_user_id: int | None = None,
|
||||
partner_user_id: int | None = None,
|
||||
status: str = "",
|
||||
q: str = "",
|
||||
include_inactive: bool = False,
|
||||
financial_year: str | None = None,
|
||||
):
|
||||
today = date.today()
|
||||
special_filter = (status or "").strip().lower()
|
||||
query = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.client),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.subscription),
|
||||
)
|
||||
.where(ClientServiceTaskInstance.tenant_id == tenant_id)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
if branch_id:
|
||||
query = query.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||
if assigned_to_user_id:
|
||||
query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
|
||||
query = _apply_partner_visibility_filter(query, partner_user_id)
|
||||
if special_filter and special_filter not in {"overdue", "due_today", "unassigned"}:
|
||||
query = query.where(ClientServiceTaskInstance.status == special_filter)
|
||||
if special_filter == "overdue":
|
||||
query = query.where(
|
||||
ClientServiceTaskInstance.internal_target_date.is_not(None),
|
||||
ClientServiceTaskInstance.internal_target_date < today,
|
||||
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
|
||||
)
|
||||
elif special_filter == "due_today":
|
||||
query = query.where(
|
||||
ClientServiceTaskInstance.internal_target_date == today,
|
||||
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
|
||||
)
|
||||
elif special_filter == "unassigned":
|
||||
query = query.where(ClientServiceTaskInstance.assigned_to_user_id.is_(None))
|
||||
if not include_inactive:
|
||||
query = query.where(ClientServiceTaskInstance.is_active.is_(True))
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = (
|
||||
query.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
or_(
|
||||
ClientServiceTaskInstance.task_name.ilike(term),
|
||||
Client.client_name.ilike(term),
|
||||
Client.client_code.ilike(term),
|
||||
ServiceCatalogue.service_code.ilike(term),
|
||||
ServiceCatalogue.service_name.ilike(term),
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = db.execute(
|
||||
query.order_by(
|
||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||
ClientServiceTaskInstance.status.asc(),
|
||||
ClientServiceTaskInstance.sequence_no.asc(),
|
||||
ClientServiceTaskInstance.id.desc(),
|
||||
)
|
||||
).scalars().all()
|
||||
return [_decorate_task_for_tracker(task, today=today) for task in rows]
|
||||
|
||||
|
||||
def get_task(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
task_id: int,
|
||||
branch_id: int | None = None,
|
||||
assigned_to_user_id: int | None = None,
|
||||
partner_user_id: int | None = None,
|
||||
financial_year: str | None = None,
|
||||
) -> ClientServiceTaskInstance | None:
|
||||
query = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.client),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.subscription),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == task_id,
|
||||
ClientServiceTaskInstance.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
if branch_id:
|
||||
query = query.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
if assigned_to_user_id:
|
||||
query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
|
||||
query = _apply_partner_visibility_filter(query, partner_user_id)
|
||||
task = db.execute(query).scalar_one_or_none()
|
||||
return _decorate_task_for_tracker(task, today=date.today()) if task else None
|
||||
|
||||
|
||||
def list_assignees_for_execution(db: Session, *, tenant_id: int, branch_id: int | None = None):
|
||||
query = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True))
|
||||
if branch_id:
|
||||
query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None)))
|
||||
return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all()
|
||||
|
||||
|
||||
def apply_task_update(
|
||||
task: ClientServiceTaskInstance,
|
||||
*,
|
||||
status: str,
|
||||
priority: str,
|
||||
assigned_to_user_id: int | None,
|
||||
internal_target_date: date | None,
|
||||
remarks: str,
|
||||
is_active: bool,
|
||||
user_id: int,
|
||||
):
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
return
|
||||
previous_status = task.status
|
||||
task.status = _normalise_status(status)
|
||||
task.priority = _normalise_priority(priority)
|
||||
task.assigned_to_user_id = assigned_to_user_id
|
||||
task.internal_target_date = internal_target_date
|
||||
task.remarks = remarks.strip() or None
|
||||
task.is_active = is_active
|
||||
task.updated_by_user_id = user_id
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc:
|
||||
task.started_at_utc = now
|
||||
if task.status == "completed" and not task.completed_at_utc:
|
||||
task.completed_at_utc = now
|
||||
if task.status != "completed":
|
||||
task.completed_at_utc = None
|
||||
|
||||
|
||||
def apply_bulk_task_update(
|
||||
tasks: list[ClientServiceTaskInstance],
|
||||
*,
|
||||
status: str | None,
|
||||
assigned_to_user_id: int | None,
|
||||
update_assignee: bool,
|
||||
internal_target_date: date | None,
|
||||
update_internal_target_date: bool,
|
||||
user_id: int,
|
||||
) -> tuple[int, int]:
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for task in tasks:
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
skipped += 1
|
||||
continue
|
||||
previous_status = task.status
|
||||
if status:
|
||||
task.status = _normalise_status(status)
|
||||
if update_assignee:
|
||||
task.assigned_to_user_id = assigned_to_user_id
|
||||
if update_internal_target_date:
|
||||
task.internal_target_date = internal_target_date
|
||||
task.updated_by_user_id = user_id
|
||||
now = datetime.now(timezone.utc)
|
||||
if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc:
|
||||
task.started_at_utc = now
|
||||
if task.status == "completed" and not task.completed_at_utc:
|
||||
task.completed_at_utc = now
|
||||
if task.status != "completed":
|
||||
task.completed_at_utc = None
|
||||
updated += 1
|
||||
return updated, skipped
|
||||
|
||||
|
||||
def get_tasks_for_bulk_update(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
task_ids: list[int],
|
||||
branch_id: int | None = None,
|
||||
assigned_to_user_id: int | None = None,
|
||||
partner_user_id: int | None = None,
|
||||
financial_year: str | None = None,
|
||||
) -> list[ClientServiceTaskInstance]:
|
||||
if not task_ids:
|
||||
return []
|
||||
query = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(selectinload(ClientServiceTaskInstance.subscription))
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == tenant_id,
|
||||
ClientServiceTaskInstance.id.in_(task_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if branch_id:
|
||||
query = query.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
if assigned_to_user_id:
|
||||
query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
|
||||
query = _apply_partner_visibility_filter(query, partner_user_id)
|
||||
return db.execute(query).scalars().all()
|
||||
|
||||
|
||||
|
||||
def list_task_comments(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskComment]:
|
||||
return db.execute(
|
||||
select(ServiceTaskComment)
|
||||
.options(selectinload(ServiceTaskComment.created_by))
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == tenant_id,
|
||||
ServiceTaskComment.task_instance_id == task_id,
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def add_task_comment(
|
||||
db: Session,
|
||||
*,
|
||||
task: ClientServiceTaskInstance,
|
||||
comment_type: str,
|
||||
visibility: str,
|
||||
message: str,
|
||||
user_id: int,
|
||||
) -> ServiceTaskComment | None:
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
return None
|
||||
clean_message = (message or "").strip()
|
||||
if not clean_message:
|
||||
return None
|
||||
row = ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type=_normalise_comment_type(comment_type),
|
||||
visibility=_normalise_visibility(visibility),
|
||||
message=clean_message,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
db.add(row)
|
||||
task.updated_by_user_id = user_id
|
||||
return row
|
||||
|
||||
|
||||
|
||||
def list_client_visible_task_comments(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
limit: int = 20,
|
||||
) -> list[ServiceTaskComment]:
|
||||
"""Return client-visible task communication for one client dashboard.
|
||||
|
||||
This is intentionally read-only and scoped by tenant + client. Internal and
|
||||
consultant-only notes are never returned to the client portal.
|
||||
"""
|
||||
safe_limit = max(1, min(int(limit or 20), 100))
|
||||
rows = db.execute(
|
||||
select(ServiceTaskComment)
|
||||
.options(
|
||||
selectinload(ServiceTaskComment.created_by),
|
||||
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.subscription),
|
||||
)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == tenant_id,
|
||||
ServiceTaskComment.visibility == "client",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id == client_id,
|
||||
ClientServiceTaskInstance.tenant_id == tenant_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(safe_limit)
|
||||
).scalars().all()
|
||||
|
||||
type_labels = dict(TASK_COMMENT_TYPES)
|
||||
visibility_labels = dict(TASK_COMMENT_VISIBILITIES)
|
||||
for row in rows:
|
||||
row.comment_type_label = type_labels.get(row.comment_type, row.comment_type)
|
||||
row.visibility_label = visibility_labels.get(row.visibility, row.visibility)
|
||||
return rows
|
||||
|
||||
|
||||
def dashboard_stats(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
assigned_to_user_id: int | None = None,
|
||||
partner_user_id: int | None = None,
|
||||
financial_year: str | None = None,
|
||||
):
|
||||
today = date.today()
|
||||
base = select(ClientServiceTaskInstance).where(
|
||||
ClientServiceTaskInstance.tenant_id == tenant_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
if branch_id:
|
||||
base = base.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||
if financial_year:
|
||||
base = base.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
if assigned_to_user_id:
|
||||
base = base.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
|
||||
base = _apply_partner_visibility_filter(base, partner_user_id)
|
||||
|
||||
subq = base.subquery()
|
||||
total = db.execute(select(func.count()).select_from(subq)).scalar_one()
|
||||
pending = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "pending")).scalar_one()
|
||||
progress = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "in_progress")).scalar_one()
|
||||
blocked = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "blocked")).scalar_one()
|
||||
completed = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "completed")).scalar_one()
|
||||
not_applicable = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "not_applicable")).scalar_one()
|
||||
unassigned = db.execute(select(func.count()).select_from(subq).where(subq.c.assigned_to_user_id.is_(None))).scalar_one()
|
||||
overdue = db.execute(
|
||||
select(func.count()).select_from(subq).where(
|
||||
subq.c.internal_target_date.is_not(None),
|
||||
subq.c.internal_target_date < today,
|
||||
subq.c.status.notin_(list(CLOSED_TASK_STATUSES)),
|
||||
)
|
||||
).scalar_one()
|
||||
due_today = db.execute(
|
||||
select(func.count()).select_from(subq).where(
|
||||
subq.c.internal_target_date == today,
|
||||
subq.c.status.notin_(list(CLOSED_TASK_STATUSES)),
|
||||
)
|
||||
).scalar_one()
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"in_progress": progress,
|
||||
"blocked": blocked,
|
||||
"completed": completed,
|
||||
"not_applicable": not_applicable,
|
||||
"unassigned": unassigned,
|
||||
"overdue": overdue,
|
||||
"due_today": due_today,
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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.core.rbac.permission_guard import require_permission
|
||||
from app.modules.services.execution import (
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
apply_task_update,
|
||||
dashboard_stats,
|
||||
generate_tasks_for_subscription,
|
||||
get_subscription_for_execution,
|
||||
get_task,
|
||||
list_assignees_for_execution,
|
||||
list_subscription_execution_payload,
|
||||
list_tasks_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/services/execution", tags=["services-execution-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **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),
|
||||
"task_statuses": TASK_STATUSES,
|
||||
"task_priorities": TASK_PRIORITIES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(
|
||||
request.session.get("active_tenant_id")
|
||||
or request.session.get("selected_tenant_id")
|
||||
or request.session.get("tenant_id")
|
||||
or user.tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "clients.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _assigned_user_filter(db, user) -> int | None:
|
||||
# Partner/Staff style users with own-only permission see only their assigned tasks.
|
||||
return int(user.id) if _has_perm(db, user, "clients.view.own_only") else None
|
||||
|
||||
|
||||
def _can_manage_execution(db, user) -> bool:
|
||||
return _has_perm(db, user, "service_tasks.edit") or _has_perm(db, user, "service_tasks.create")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def execution_dashboard(request: Request, q: str = "", status: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
assigned_to_user_id = _assigned_user_filter(db, user)
|
||||
stats = dashboard_stats(db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=assigned_to_user_id)
|
||||
tasks = list_tasks_payload(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
status=status,
|
||||
q=q,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/execution/dashboard.html",
|
||||
db,
|
||||
user,
|
||||
title="Service Execution Dashboard",
|
||||
stats=stats,
|
||||
tasks=tasks,
|
||||
q=q,
|
||||
status=status,
|
||||
can_manage=_can_manage_execution(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
def subscription_execution_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
rows = list_subscription_execution_payload(db, tenant_id=tenant_id, branch_id=branch_id, q=q)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/execution/subscriptions.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Service Tasks",
|
||||
rows=rows,
|
||||
q=q,
|
||||
can_generate=_has_perm(db, user, "service_tasks.create"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/subscriptions/{subscription_id}/generate")
|
||||
def generate_subscription_tasks(request: Request, subscription_id: int, csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.create")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
subscription = get_subscription_for_execution(db, tenant_id=tenant_id, subscription_id=subscription_id)
|
||||
if not subscription or not subscription.is_active or subscription.status != "active":
|
||||
return RedirectResponse(url="/services/execution/subscriptions", status_code=303)
|
||||
|
||||
generate_tasks_for_subscription(db, subscription=subscription, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/execution", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/edit")
|
||||
def task_edit_page(request: Request, task_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
task = get_task(db, tenant_id=tenant_id, task_id=task_id)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/execution", status_code=303)
|
||||
|
||||
own_only_user_id = _assigned_user_filter(db, user)
|
||||
if own_only_user_id and task.assigned_to_user_id != own_only_user_id:
|
||||
return _redirect_denied()
|
||||
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/execution/task_form.html",
|
||||
db,
|
||||
user,
|
||||
title="Update Service Task",
|
||||
task=task,
|
||||
assignees=assignees,
|
||||
can_edit=_has_perm(db, user, "service_tasks.edit"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/edit")
|
||||
def task_edit_submit(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
status: str = Form("pending"),
|
||||
priority: str = Form("normal"),
|
||||
assigned_to_user_id: str = Form(""),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.edit")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
task = get_task(db, tenant_id=tenant_id, task_id=task_id)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/execution", status_code=303)
|
||||
|
||||
own_only_user_id = _assigned_user_filter(db, user)
|
||||
if own_only_user_id and task.assigned_to_user_id != own_only_user_id:
|
||||
return _redirect_denied()
|
||||
|
||||
apply_task_update(
|
||||
task,
|
||||
status=status,
|
||||
priority=priority,
|
||||
assigned_to_user_id=int(assigned_to_user_id) if assigned_to_user_id.strip() else None,
|
||||
remarks=remarks,
|
||||
is_active=is_active is not None,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/execution", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Font
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.services.models import FirmServiceTaskTemplate, ServiceCatalogue
|
||||
|
||||
|
||||
def build_import_template_workbook() -> bytes:
|
||||
wb = Workbook()
|
||||
|
||||
ws_services = wb.active
|
||||
ws_services.title = "service_catalogue"
|
||||
service_headers = [
|
||||
"service_code",
|
||||
"service_name",
|
||||
"category",
|
||||
"description",
|
||||
"recurrence_type",
|
||||
"is_active",
|
||||
"is_client_requestable",
|
||||
"is_consultant_requestable",
|
||||
]
|
||||
ws_services.append(service_headers)
|
||||
ws_services.append([
|
||||
"GST-MONTHLY",
|
||||
"GST Monthly Return Filing",
|
||||
"GST",
|
||||
"Monthly GST compliance service",
|
||||
"MONTHLY",
|
||||
"TRUE",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
])
|
||||
|
||||
ws_tasks = wb.create_sheet("firm_task_templates")
|
||||
task_headers = [
|
||||
"service_code",
|
||||
"sequence_no",
|
||||
"task_name",
|
||||
"description",
|
||||
"default_role_name",
|
||||
"sla_days",
|
||||
"is_mandatory",
|
||||
"requires_review",
|
||||
"is_active",
|
||||
]
|
||||
ws_tasks.append(task_headers)
|
||||
ws_tasks.append([
|
||||
"GST-MONTHLY",
|
||||
1,
|
||||
"Collect Purchase and Sales Data",
|
||||
"Collect source data from client",
|
||||
"Staff",
|
||||
3,
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"TRUE",
|
||||
])
|
||||
ws_tasks.append([
|
||||
"GST-MONTHLY",
|
||||
2,
|
||||
"Review and File Return",
|
||||
"Manager review and final filing",
|
||||
"Partner",
|
||||
2,
|
||||
"TRUE",
|
||||
"TRUE",
|
||||
"TRUE",
|
||||
])
|
||||
|
||||
for ws in [ws_services, ws_tasks]:
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True)
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
val = "" if cell.value is None else str(cell.value)
|
||||
max_len = max(max_len, len(val))
|
||||
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 14), 40)
|
||||
|
||||
out = BytesIO()
|
||||
wb.save(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _norm_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _norm_upper(value: Any) -> str:
|
||||
return _norm_text(value).upper()
|
||||
|
||||
|
||||
def _norm_bool(value: Any, default: bool = False) -> bool:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _norm_int(value: Any, default: int | None = None) -> int | None:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _sheet_headers(ws) -> dict[str, int]:
|
||||
headers = {}
|
||||
first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), [])
|
||||
for idx, val in enumerate(first_row):
|
||||
key = _norm_text(val).lower()
|
||||
if key:
|
||||
headers[key] = idx
|
||||
return headers
|
||||
|
||||
|
||||
def _cell(row: tuple, headers: dict[str, int], key: str) -> Any:
|
||||
idx = headers.get(key.lower())
|
||||
if idx is None or idx >= len(row):
|
||||
return None
|
||||
return row[idx]
|
||||
|
||||
|
||||
def parse_import_workbook(file_bytes: bytes) -> dict:
|
||||
wb = load_workbook(BytesIO(file_bytes), data_only=True)
|
||||
errors: list[str] = []
|
||||
catalogue_rows: list[dict] = []
|
||||
task_rows: list[dict] = []
|
||||
|
||||
if "service_catalogue" not in wb.sheetnames:
|
||||
return {"ok": False, "errors": ["Workbook must contain a sheet named 'service_catalogue'."], "catalogue_rows": [], "task_rows": []}
|
||||
|
||||
ws_services = wb["service_catalogue"]
|
||||
headers = _sheet_headers(ws_services)
|
||||
for h in ["service_code", "service_name"]:
|
||||
if h not in headers:
|
||||
errors.append(f"Service catalogue sheet missing required column: {h}")
|
||||
|
||||
for row_no, row in enumerate(ws_services.iter_rows(min_row=2, values_only=True), start=2):
|
||||
service_code = _norm_upper(_cell(row, headers, "service_code"))
|
||||
service_name = _norm_text(_cell(row, headers, "service_name"))
|
||||
if not service_code and not service_name:
|
||||
continue
|
||||
if not service_code:
|
||||
errors.append(f"Service catalogue row {row_no}: service_code is required.")
|
||||
continue
|
||||
if not service_name:
|
||||
errors.append(f"Service catalogue row {row_no}: service_name is required.")
|
||||
continue
|
||||
catalogue_rows.append({
|
||||
"service_code": service_code,
|
||||
"service_name": service_name,
|
||||
"category": _norm_text(_cell(row, headers, "category")) or None,
|
||||
"description": _norm_text(_cell(row, headers, "description")) or None,
|
||||
"recurrence_type": _norm_text(_cell(row, headers, "recurrence_type")) or None,
|
||||
"is_active": _norm_bool(_cell(row, headers, "is_active"), True),
|
||||
"is_client_requestable": _norm_bool(_cell(row, headers, "is_client_requestable"), False),
|
||||
"is_consultant_requestable": _norm_bool(_cell(row, headers, "is_consultant_requestable"), False),
|
||||
})
|
||||
|
||||
if "firm_task_templates" in wb.sheetnames:
|
||||
ws_tasks = wb["firm_task_templates"]
|
||||
task_headers = _sheet_headers(ws_tasks)
|
||||
for h in ["service_code", "sequence_no", "task_name"]:
|
||||
if h not in task_headers:
|
||||
errors.append(f"Firm task templates sheet missing required column: {h}")
|
||||
for row_no, row in enumerate(ws_tasks.iter_rows(min_row=2, values_only=True), start=2):
|
||||
service_code = _norm_upper(_cell(row, task_headers, "service_code"))
|
||||
task_name = _norm_text(_cell(row, task_headers, "task_name"))
|
||||
sequence_no = _norm_int(_cell(row, task_headers, "sequence_no"))
|
||||
if not service_code and not task_name:
|
||||
continue
|
||||
if not service_code:
|
||||
errors.append(f"Firm task templates row {row_no}: service_code is required.")
|
||||
continue
|
||||
if not task_name:
|
||||
errors.append(f"Firm task templates row {row_no}: task_name is required.")
|
||||
continue
|
||||
if sequence_no is None:
|
||||
errors.append(f"Firm task templates row {row_no}: sequence_no must be numeric.")
|
||||
continue
|
||||
task_rows.append({
|
||||
"service_code": service_code,
|
||||
"sequence_no": sequence_no,
|
||||
"task_name": task_name,
|
||||
"description": _norm_text(_cell(row, task_headers, "description")) or None,
|
||||
"default_role_name": _norm_text(_cell(row, task_headers, "default_role_name")) or None,
|
||||
"sla_days": _norm_int(_cell(row, task_headers, "sla_days")),
|
||||
"is_mandatory": _norm_bool(_cell(row, task_headers, "is_mandatory"), True),
|
||||
"requires_review": _norm_bool(_cell(row, task_headers, "requires_review"), False),
|
||||
"is_active": _norm_bool(_cell(row, task_headers, "is_active"), True),
|
||||
})
|
||||
|
||||
service_codes = {row["service_code"] for row in catalogue_rows}
|
||||
for row in task_rows:
|
||||
if row["service_code"] not in service_codes:
|
||||
errors.append(f"Task row for service_code '{row['service_code']}' does not match any service in service_catalogue sheet.")
|
||||
|
||||
return {"ok": len(errors) == 0, "errors": errors, "catalogue_rows": catalogue_rows, "task_rows": task_rows}
|
||||
|
||||
|
||||
def apply_import_payload(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None,
|
||||
actor_user_id: int,
|
||||
catalogue_rows: list[dict],
|
||||
task_rows: list[dict],
|
||||
) -> dict:
|
||||
created_catalogue = 0
|
||||
updated_catalogue = 0
|
||||
created_tasks = 0
|
||||
updated_tasks = 0
|
||||
catalogue_map: dict[str, ServiceCatalogue] = {}
|
||||
|
||||
for row in catalogue_rows:
|
||||
service = db.execute(select(ServiceCatalogue).where(ServiceCatalogue.service_code == row["service_code"])).scalar_one_or_none()
|
||||
if service is None:
|
||||
service = ServiceCatalogue(
|
||||
service_code=row["service_code"],
|
||||
service_name=row["service_name"],
|
||||
category=row["category"],
|
||||
description=row["description"],
|
||||
recurrence_type=row["recurrence_type"],
|
||||
is_active=row["is_active"],
|
||||
is_client_requestable=row["is_client_requestable"],
|
||||
is_consultant_requestable=row["is_consultant_requestable"],
|
||||
created_by_user_id=actor_user_id,
|
||||
updated_by_user_id=actor_user_id,
|
||||
)
|
||||
db.add(service)
|
||||
db.flush()
|
||||
created_catalogue += 1
|
||||
else:
|
||||
service.service_name = row["service_name"]
|
||||
service.category = row["category"]
|
||||
service.description = row["description"]
|
||||
service.recurrence_type = row["recurrence_type"]
|
||||
service.is_active = row["is_active"]
|
||||
service.is_client_requestable = row["is_client_requestable"]
|
||||
service.is_consultant_requestable = row["is_consultant_requestable"]
|
||||
service.updated_by_user_id = actor_user_id
|
||||
updated_catalogue += 1
|
||||
catalogue_map[row["service_code"]] = service
|
||||
|
||||
for row in task_rows:
|
||||
service = catalogue_map[row["service_code"]]
|
||||
task = db.execute(
|
||||
select(FirmServiceTaskTemplate).where(
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == service.id,
|
||||
FirmServiceTaskTemplate.sequence_no == row["sequence_no"],
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if task is None:
|
||||
task = FirmServiceTaskTemplate(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
service_catalogue_id=service.id,
|
||||
sequence_no=row["sequence_no"],
|
||||
task_name=row["task_name"],
|
||||
description=row["description"],
|
||||
default_role_name=row["default_role_name"],
|
||||
sla_days=row["sla_days"],
|
||||
is_mandatory=row["is_mandatory"],
|
||||
requires_review=row["requires_review"],
|
||||
is_active=row["is_active"],
|
||||
created_by_user_id=actor_user_id,
|
||||
updated_by_user_id=actor_user_id,
|
||||
)
|
||||
db.add(task)
|
||||
created_tasks += 1
|
||||
else:
|
||||
task.task_name = row["task_name"]
|
||||
task.description = row["description"]
|
||||
task.default_role_name = row["default_role_name"]
|
||||
task.sla_days = row["sla_days"]
|
||||
task.is_mandatory = row["is_mandatory"]
|
||||
task.requires_review = row["requires_review"]
|
||||
task.is_active = row["is_active"]
|
||||
task.branch_id = branch_id
|
||||
task.updated_by_user_id = actor_user_id
|
||||
updated_tasks += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"created_catalogue": created_catalogue,
|
||||
"updated_catalogue": updated_catalogue,
|
||||
"created_tasks": created_tasks,
|
||||
"updated_tasks": updated_tasks,
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ServiceCategory(CommonBase):
|
||||
__tablename__ = "service_categories"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", name="uq_service_categories_code"),
|
||||
UniqueConstraint("name", name="uq_service_categories_name"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
catalogue_items = relationship("ServiceCatalogue", back_populates="service_category")
|
||||
|
||||
|
||||
class ServiceCatalogue(CommonBase):
|
||||
__tablename__ = "service_catalogues"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_code", name="uq_service_catalogues_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
service_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
service_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("service_categories.id"), nullable=True, index=True)
|
||||
recurrence_type: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
applicable_individual: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
applicable_proprietorship: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
applicable_partnership: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
applicable_llp: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
applicable_company: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
applicable_trust: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
applicable_society: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_client_requestable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_consultant_requestable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
service_category = relationship("ServiceCategory", back_populates="catalogue_items")
|
||||
firm_services = relationship(
|
||||
"FirmServiceSelection",
|
||||
back_populates="catalogue",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
task_templates = relationship(
|
||||
"FirmServiceTaskTemplate",
|
||||
back_populates="catalogue",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
default_task_templates = relationship(
|
||||
"ServiceDefaultTaskTemplate",
|
||||
back_populates="catalogue",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="ServiceDefaultTaskTemplate.sequence_no.asc()",
|
||||
)
|
||||
|
||||
due_date_rules = relationship(
|
||||
"ServiceDueDateRule",
|
||||
back_populates="catalogue",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="ServiceDueDateRule.sort_order.asc()",
|
||||
)
|
||||
|
||||
|
||||
class ServiceDefaultTaskTemplate(CommonBase):
|
||||
__tablename__ = "service_default_task_templates"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_catalogue_id", "sequence_no", name="uq_service_default_task_templates_sequence"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
task_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
catalogue = relationship("ServiceCatalogue", back_populates="default_task_templates")
|
||||
|
||||
|
||||
class FirmServiceSelection(CommonBase):
|
||||
__tablename__ = "firm_service_selections"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "service_catalogue_id", name="uq_firm_service_selections_tenant_catalogue"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
default_branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
activated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
activated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
catalogue = relationship("ServiceCatalogue", back_populates="firm_services")
|
||||
|
||||
|
||||
class FirmServiceTaskTemplate(CommonBase):
|
||||
__tablename__ = "firm_service_task_templates"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "service_catalogue_id", "sequence_no",
|
||||
name="uq_firm_service_task_templates_sequence",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
task_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
catalogue = relationship("ServiceCatalogue", back_populates="task_templates")
|
||||
document_requirements = relationship(
|
||||
"FirmTaskDocumentRequirement",
|
||||
back_populates="task_template",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="FirmTaskDocumentRequirement.sort_order.asc()",
|
||||
)
|
||||
document_templates = relationship(
|
||||
"FirmTaskDocumentTemplate",
|
||||
back_populates="task_template",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="FirmTaskDocumentTemplate.uploaded_at_utc.desc()",
|
||||
)
|
||||
|
||||
|
||||
class FirmTaskDocumentRequirement(CommonBase):
|
||||
"""Document required at service task-template level.
|
||||
|
||||
These rows define what must/should be collected when engagement task
|
||||
instances are generated from a firm task template. Actual uploaded files are
|
||||
linked to ClientServiceTaskInstance through EngagementDocument.task_instance_id.
|
||||
"""
|
||||
|
||||
__tablename__ = "firm_task_document_requirements"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"firm_task_template_id",
|
||||
"document_name",
|
||||
name="uq_firm_task_document_requirements_name",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
firm_task_template_id: Mapped[int] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
document_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
document_type: Mapped[str] = mapped_column(String(80), nullable=False, default="GENERAL", index=True)
|
||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allowed_file_types: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
instructions: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
task_template = relationship("FirmServiceTaskTemplate", back_populates="document_requirements")
|
||||
catalogue = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class FirmTaskDocumentTemplate(CommonBase):
|
||||
"""Reusable uploaded template file attached to a firm task template.
|
||||
|
||||
Example: GST registration NOC format, partnership deed format, agreement
|
||||
draft, company incorporation checklist, board resolution format etc.
|
||||
"""
|
||||
|
||||
__tablename__ = "firm_task_document_templates"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
firm_task_template_id: Mapped[int] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
template_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
template_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content_type: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
local_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
|
||||
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
uploaded_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
task_template = relationship("FirmServiceTaskTemplate", back_populates="document_templates")
|
||||
catalogue = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class ServiceDueDateRule(CommonBase):
|
||||
"""Statutory/compliance due-date rule attached to a service catalogue item."""
|
||||
|
||||
__tablename__ = "service_due_date_rules"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_catalogue_id", "rule_name", name="uq_service_due_date_rules_catalogue_name"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
rule_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
period_type: Mapped[str] = mapped_column(String(30), nullable=False, default="yearly", index=True)
|
||||
due_year_basis: Mapped[str] = mapped_column(String(40), nullable=False, default="assessment_year_start")
|
||||
due_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
due_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
due_month_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
days_offset_after_event: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
renewal_days_before_expiry: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
catalogue = relationship("ServiceCatalogue", back_populates="due_date_rules")
|
||||
|
||||
|
||||
class ServiceDueDateExtension(CommonBase):
|
||||
"""History of statutory due-date extensions for a service/rule/FY/period."""
|
||||
|
||||
__tablename__ = "service_due_date_extensions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
due_date_rule_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("service_due_date_rules.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
financial_year: Mapped[str] = mapped_column(String(9), nullable=False, index=True)
|
||||
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
period_label: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
previous_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
extended_due_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
extension_sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
notification_reference: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
notification_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
catalogue = relationship("ServiceCatalogue")
|
||||
due_rule = relationship("ServiceDueDateRule")
|
||||
|
||||
|
||||
class ClientServiceSubscription(CommonBase):
|
||||
"""Firm-level subscription of an enabled service to a specific client."""
|
||||
|
||||
__tablename__ = "client_service_subscriptions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"service_catalogue_id",
|
||||
"financial_year",
|
||||
name="uq_client_service_subscription_tenant_client_service_year",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
assigned_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
assigned_staff_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
|
||||
financial_year: Mapped[str] = mapped_column(String(9), nullable=False, default="2025-26", index=True)
|
||||
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True)
|
||||
due_date_rule_id: Mapped[int | None] = mapped_column(ForeignKey("service_due_date_rules.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
original_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
current_due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
due_date_source: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
locked_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
catalogue = relationship("ServiceCatalogue")
|
||||
due_date_rule = relationship("ServiceDueDateRule", foreign_keys=[due_date_rule_id])
|
||||
firm_selection = relationship("FirmServiceSelection")
|
||||
assigned_partner = relationship("User", foreign_keys=[assigned_partner_user_id])
|
||||
assigned_manager = relationship("User", foreign_keys=[assigned_manager_user_id])
|
||||
assigned_staff = relationship("User", foreign_keys=[assigned_staff_user_id])
|
||||
review_partner = relationship("User", foreign_keys=[review_partner_user_id])
|
||||
locked_by = relationship("User", foreign_keys=[locked_by_user_id])
|
||||
|
||||
|
||||
class ClientServiceTaskInstance(CommonBase):
|
||||
"""Execution task generated from a firm service task template for a client-service subscription."""
|
||||
|
||||
__tablename__ = "client_service_task_instances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"subscription_id",
|
||||
"firm_task_template_id",
|
||||
"financial_year",
|
||||
name="uq_client_service_task_subscription_template_year",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
firm_task_template_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
financial_year: Mapped[str] = mapped_column(String(9), nullable=False, default="2025-26", index=True)
|
||||
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
task_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sequence_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
default_role_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
internal_target_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
started_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
locked_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
subscription = relationship("ClientServiceSubscription")
|
||||
client = relationship("Client")
|
||||
catalogue = relationship("ServiceCatalogue")
|
||||
template = relationship("FirmServiceTaskTemplate")
|
||||
assigned_to = relationship("User", foreign_keys=[assigned_to_user_id])
|
||||
locked_by = relationship("User", foreign_keys=[locked_by_user_id])
|
||||
documents = relationship(
|
||||
"EngagementDocument",
|
||||
primaryjoin="ClientServiceTaskInstance.id == foreign(EngagementDocument.task_instance_id)",
|
||||
viewonly=True,
|
||||
order_by="EngagementDocument.updated_at_utc.desc()",
|
||||
)
|
||||
comments = relationship(
|
||||
"ServiceTaskComment",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
order_by="ServiceTaskComment.created_at_utc.desc()",
|
||||
)
|
||||
|
||||
|
||||
class ServiceTaskComment(CommonBase):
|
||||
"""Communication timeline entry linked to a service task instance."""
|
||||
|
||||
__tablename__ = "service_task_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
task_instance_id: Mapped[int] = mapped_column(ForeignKey("client_service_task_instances.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
comment_type: Mapped[str] = mapped_column(String(40), nullable=False, default="internal_note", index=True)
|
||||
visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="internal", index=True)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
|
||||
task = relationship("ClientServiceTaskInstance", back_populates="comments")
|
||||
subscription = relationship("ClientServiceSubscription")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.core.tenancy.models import Branch
|
||||
from app.modules.services.models import (
|
||||
FirmServiceSelection,
|
||||
FirmServiceTaskTemplate,
|
||||
ServiceCatalogue,
|
||||
ServiceCategory,
|
||||
ServiceDefaultTaskTemplate,
|
||||
ServiceDueDateRule,
|
||||
)
|
||||
|
||||
RECURRENCE_CHOICES = [
|
||||
("one_time", "One Time"),
|
||||
("monthly", "Monthly"),
|
||||
("quarterly", "Quarterly"),
|
||||
("yearly", "Yearly"),
|
||||
("event_based", "Event Based"),
|
||||
("custom", "Custom"),
|
||||
]
|
||||
|
||||
ENGAGEMENT_TYPE_CHOICES = [
|
||||
("assurance", "Assurance"),
|
||||
("non_assurance", "Non-Assurance"),
|
||||
]
|
||||
|
||||
VALID_ENGAGEMENT_TYPES = {value for value, _label in ENGAGEMENT_TYPE_CHOICES}
|
||||
|
||||
|
||||
def normalize_engagement_type(value: str | None) -> str:
|
||||
value = (value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if value in {"assurance", "audit", "aud", "certification", "certificate", "attestation"}:
|
||||
return "assurance"
|
||||
if value in {"non_assurance", "nonassurance", "non_audit", "nonaudit", "non", "compliance", "consulting", "consultancy"}:
|
||||
return "non_assurance"
|
||||
return "non_assurance"
|
||||
|
||||
|
||||
def engagement_type_label(value: str | None) -> str:
|
||||
normalized = normalize_engagement_type(value)
|
||||
return "Assurance" if normalized == "assurance" else "Non-Assurance"
|
||||
|
||||
|
||||
def normalize_code(value: str) -> str:
|
||||
value = (value or "").strip().upper()
|
||||
value = re.sub(r"[^A-Z0-9]+", "-", value)
|
||||
value = re.sub(r"-+", "-", value).strip("-")
|
||||
return value
|
||||
|
||||
|
||||
def list_categories(db: Session, *, q: str = ""):
|
||||
query = select(ServiceCategory)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = query.where(or_(ServiceCategory.code.ilike(term), ServiceCategory.name.ilike(term)))
|
||||
return db.execute(query.order_by(ServiceCategory.sort_order.asc(), ServiceCategory.name.asc())).scalars().all()
|
||||
|
||||
|
||||
def get_category(db: Session, category_id: int) -> ServiceCategory | None:
|
||||
return db.execute(select(ServiceCategory).where(ServiceCategory.id == category_id)).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_catalogue_payload(db: Session, *, q: str = "", category_id: int | None = None, recurrence_type: str = "", engagement_type: str = "", page: int = 1, per_page: int = 20):
|
||||
query = select(ServiceCatalogue).options(
|
||||
selectinload(ServiceCatalogue.service_category),
|
||||
selectinload(ServiceCatalogue.default_task_templates),
|
||||
selectinload(ServiceCatalogue.due_date_rules),
|
||||
)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where(
|
||||
or_(
|
||||
ServiceCatalogue.service_code.ilike(term),
|
||||
ServiceCatalogue.service_name.ilike(term),
|
||||
ServiceCatalogue.category.ilike(term),
|
||||
ServiceCategory.name.ilike(term),
|
||||
)
|
||||
)
|
||||
if category_id:
|
||||
query = query.where(ServiceCatalogue.category_id == category_id)
|
||||
if recurrence_type.strip():
|
||||
query = query.where(ServiceCatalogue.recurrence_type == recurrence_type.strip())
|
||||
if engagement_type.strip():
|
||||
query = query.where(ServiceCatalogue.engagement_type == normalize_engagement_type(engagement_type))
|
||||
|
||||
total = db.execute(select(func.count()).select_from(query.subquery())).scalar_one()
|
||||
rows = db.execute(
|
||||
query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"rows": rows,
|
||||
"q": q,
|
||||
"category_id": category_id,
|
||||
"recurrence_type": recurrence_type,
|
||||
"engagement_type": engagement_type,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": max(1, (total + per_page - 1) // per_page),
|
||||
}
|
||||
|
||||
|
||||
def list_firm_services_payload(db: Session, *, tenant_id: int, q: str = ""):
|
||||
query = (
|
||||
select(FirmServiceSelection, ServiceCatalogue, Branch)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id)
|
||||
.outerjoin(Branch, Branch.id == FirmServiceSelection.default_branch_id)
|
||||
.where(
|
||||
FirmServiceSelection.tenant_id == tenant_id,
|
||||
FirmServiceSelection.is_enabled.is_(True),
|
||||
)
|
||||
)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where(
|
||||
or_(
|
||||
ServiceCatalogue.service_code.ilike(term),
|
||||
ServiceCatalogue.service_name.ilike(term),
|
||||
ServiceCatalogue.category.ilike(term),
|
||||
ServiceCategory.name.ilike(term),
|
||||
)
|
||||
)
|
||||
rows = db.execute(query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())).all()
|
||||
return [
|
||||
{"selection": selection, "catalogue": catalogue, "branch": branch}
|
||||
for selection, catalogue, branch in rows
|
||||
]
|
||||
|
||||
|
||||
def list_disabled_catalogues(db: Session, *, tenant_id: int, q: str = ""):
|
||||
enabled_subq = (
|
||||
select(FirmServiceSelection.service_catalogue_id)
|
||||
.where(
|
||||
FirmServiceSelection.tenant_id == tenant_id,
|
||||
FirmServiceSelection.is_enabled.is_(True),
|
||||
)
|
||||
)
|
||||
|
||||
query = select(ServiceCatalogue).where(~ServiceCatalogue.id.in_(enabled_subq)).options(
|
||||
selectinload(ServiceCatalogue.service_category),
|
||||
selectinload(ServiceCatalogue.default_task_templates),
|
||||
selectinload(ServiceCatalogue.due_date_rules),
|
||||
)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where(
|
||||
or_(
|
||||
ServiceCatalogue.service_code.ilike(term),
|
||||
ServiceCatalogue.service_name.ilike(term),
|
||||
ServiceCatalogue.category.ilike(term),
|
||||
ServiceCategory.name.ilike(term),
|
||||
)
|
||||
)
|
||||
return db.execute(query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())).scalars().all()
|
||||
|
||||
|
||||
def get_catalogue(db: Session, catalogue_id: int) -> ServiceCatalogue | None:
|
||||
return db.execute(
|
||||
select(ServiceCatalogue)
|
||||
.options(
|
||||
selectinload(ServiceCatalogue.service_category),
|
||||
selectinload(ServiceCatalogue.default_task_templates),
|
||||
)
|
||||
.where(ServiceCatalogue.id == catalogue_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_firm_selection(db: Session, *, tenant_id: int, catalogue_id: int) -> FirmServiceSelection | None:
|
||||
return db.execute(
|
||||
select(FirmServiceSelection).where(
|
||||
FirmServiceSelection.tenant_id == tenant_id,
|
||||
FirmServiceSelection.service_catalogue_id == catalogue_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_firm_task_templates(db: Session, *, tenant_id: int, catalogue_id: int):
|
||||
return db.execute(
|
||||
select(FirmServiceTaskTemplate)
|
||||
.where(
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)
|
||||
.order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_default_task_templates(db: Session, *, catalogue_id: int):
|
||||
return db.execute(
|
||||
select(ServiceDefaultTaskTemplate)
|
||||
.where(ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id)
|
||||
.order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def next_task_sequence(db: Session, *, tenant_id: int, catalogue_id: int) -> int:
|
||||
max_seq = db.execute(
|
||||
select(func.max(FirmServiceTaskTemplate.sequence_no)).where(
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)
|
||||
).scalar_one()
|
||||
return int(max_seq or 0) + 1
|
||||
|
||||
|
||||
def next_default_task_sequence(db: Session, *, catalogue_id: int) -> int:
|
||||
max_seq = db.execute(
|
||||
select(func.max(ServiceDefaultTaskTemplate.sequence_no)).where(
|
||||
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)
|
||||
).scalar_one()
|
||||
return int(max_seq or 0) + 1
|
||||
|
||||
def get_firm_task_template(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
catalogue_id: int,
|
||||
task_id: int,
|
||||
) -> FirmServiceTaskTemplate | None:
|
||||
return db.execute(
|
||||
select(FirmServiceTaskTemplate).where(
|
||||
FirmServiceTaskTemplate.id == task_id,
|
||||
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||
FirmServiceTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_default_task_template(
|
||||
db: Session,
|
||||
*,
|
||||
catalogue_id: int,
|
||||
task_id: int,
|
||||
) -> ServiceDefaultTaskTemplate | None:
|
||||
return db.execute(
|
||||
select(ServiceDefaultTaskTemplate).where(
|
||||
ServiceDefaultTaskTemplate.id == task_id,
|
||||
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.documents.models import EngagementDocument
|
||||
from app.modules.documents.services import DEFAULT_STORAGE_ROOT, sanitize_segment, save_uploaded_revision
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
FirmServiceTaskTemplate,
|
||||
FirmTaskDocumentRequirement,
|
||||
FirmTaskDocumentTemplate,
|
||||
)
|
||||
|
||||
TEMPLATE_UPLOAD_ROOT = Path(os.getenv("DOCUMENT_TEMPLATE_STORAGE_ROOT", str(DEFAULT_STORAGE_ROOT.parent / "document_templates"))).resolve()
|
||||
|
||||
|
||||
def list_task_document_requirements(db: Session, *, tenant_id: int, firm_task_template_id: int) -> list[FirmTaskDocumentRequirement]:
|
||||
return db.execute(
|
||||
select(FirmTaskDocumentRequirement)
|
||||
.where(
|
||||
FirmTaskDocumentRequirement.tenant_id == int(tenant_id),
|
||||
FirmTaskDocumentRequirement.firm_task_template_id == int(firm_task_template_id),
|
||||
)
|
||||
.order_by(FirmTaskDocumentRequirement.sort_order.asc(), FirmTaskDocumentRequirement.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_task_document_requirement(db: Session, *, requirement_id: int, tenant_id: int | None = None) -> FirmTaskDocumentRequirement | None:
|
||||
stmt = select(FirmTaskDocumentRequirement).where(FirmTaskDocumentRequirement.id == int(requirement_id))
|
||||
if tenant_id is not None:
|
||||
stmt = stmt.where(FirmTaskDocumentRequirement.tenant_id == int(tenant_id))
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_task_document_requirement(
|
||||
db: Session,
|
||||
*,
|
||||
task_template: FirmServiceTaskTemplate,
|
||||
document_name: str,
|
||||
document_type: str,
|
||||
is_mandatory: bool,
|
||||
allowed_file_types: str | None,
|
||||
instructions: str | None,
|
||||
sort_order: int,
|
||||
user,
|
||||
) -> FirmTaskDocumentRequirement:
|
||||
row = FirmTaskDocumentRequirement(
|
||||
tenant_id=task_template.tenant_id,
|
||||
service_catalogue_id=task_template.service_catalogue_id,
|
||||
firm_task_template_id=task_template.id,
|
||||
document_name=document_name.strip()[:200],
|
||||
document_type=(document_type or "GENERAL").strip().upper()[:80] or "GENERAL",
|
||||
is_mandatory=bool(is_mandatory),
|
||||
allowed_file_types=(allowed_file_types or "").strip()[:255] or None,
|
||||
instructions=(instructions or "").strip() or None,
|
||||
sort_order=int(sort_order or 100),
|
||||
is_active=True,
|
||||
created_by_user_id=getattr(user, "id", None),
|
||||
updated_by_user_id=getattr(user, "id", None),
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def update_task_document_requirement(
|
||||
db: Session,
|
||||
*,
|
||||
requirement: FirmTaskDocumentRequirement,
|
||||
document_name: str,
|
||||
document_type: str,
|
||||
is_mandatory: bool,
|
||||
allowed_file_types: str | None,
|
||||
instructions: str | None,
|
||||
sort_order: int,
|
||||
is_active: bool,
|
||||
user,
|
||||
) -> FirmTaskDocumentRequirement:
|
||||
requirement.document_name = document_name.strip()[:200]
|
||||
requirement.document_type = (document_type or "GENERAL").strip().upper()[:80] or "GENERAL"
|
||||
requirement.is_mandatory = bool(is_mandatory)
|
||||
requirement.allowed_file_types = (allowed_file_types or "").strip()[:255] or None
|
||||
requirement.instructions = (instructions or "").strip() or None
|
||||
requirement.sort_order = int(sort_order or 100)
|
||||
requirement.is_active = bool(is_active)
|
||||
requirement.updated_by_user_id = getattr(user, "id", None)
|
||||
db.flush()
|
||||
return requirement
|
||||
|
||||
|
||||
def list_task_document_templates(db: Session, *, tenant_id: int, firm_task_template_id: int) -> list[FirmTaskDocumentTemplate]:
|
||||
return db.execute(
|
||||
select(FirmTaskDocumentTemplate)
|
||||
.where(
|
||||
FirmTaskDocumentTemplate.tenant_id == int(tenant_id),
|
||||
FirmTaskDocumentTemplate.firm_task_template_id == int(firm_task_template_id),
|
||||
FirmTaskDocumentTemplate.is_active.is_(True),
|
||||
)
|
||||
.order_by(FirmTaskDocumentTemplate.uploaded_at_utc.desc(), FirmTaskDocumentTemplate.id.desc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def _template_relative_path(task_template: FirmServiceTaskTemplate, original_filename: str, template_id: int) -> Path:
|
||||
suffix = Path(original_filename or "template.bin").suffix or ".bin"
|
||||
safe_name = sanitize_segment(Path(original_filename or "template.bin").stem, "template")[:80]
|
||||
return (
|
||||
Path(f"tenant_{task_template.tenant_id}")
|
||||
/ f"service_{task_template.service_catalogue_id}"
|
||||
/ f"task_{task_template.id}"
|
||||
/ f"TPL{template_id:06d}_{safe_name}_{uuid4().hex[:8]}{suffix}"
|
||||
)
|
||||
|
||||
|
||||
def save_task_document_template(
|
||||
db: Session,
|
||||
*,
|
||||
task_template: FirmServiceTaskTemplate,
|
||||
template_name: str,
|
||||
template_category: str | None,
|
||||
description: str | None,
|
||||
upload_file,
|
||||
user,
|
||||
) -> FirmTaskDocumentTemplate:
|
||||
original_filename = Path(upload_file.filename or "template.bin").name
|
||||
row = FirmTaskDocumentTemplate(
|
||||
tenant_id=task_template.tenant_id,
|
||||
service_catalogue_id=task_template.service_catalogue_id,
|
||||
firm_task_template_id=task_template.id,
|
||||
template_name=(template_name or original_filename).strip()[:200],
|
||||
template_category=(template_category or "").strip()[:100] or None,
|
||||
description=(description or "").strip() or None,
|
||||
original_filename=original_filename,
|
||||
stored_filename="PENDING",
|
||||
content_type=getattr(upload_file, "content_type", None),
|
||||
file_size_bytes=0,
|
||||
local_relative_path="PENDING",
|
||||
uploaded_by_user_id=getattr(user, "id", None),
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
|
||||
rel_path = _template_relative_path(task_template, original_filename, row.id)
|
||||
abs_path = TEMPLATE_UPLOAD_ROOT / rel_path
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
total = 0
|
||||
with abs_path.open("wb") as out:
|
||||
while True:
|
||||
chunk = upload_file.file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
out.write(chunk)
|
||||
row.stored_filename = abs_path.name
|
||||
row.file_size_bytes = total
|
||||
row.local_relative_path = str(rel_path).replace("\\", "/")
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def template_absolute_path(template: FirmTaskDocumentTemplate) -> Path:
|
||||
return TEMPLATE_UPLOAD_ROOT / (template.local_relative_path or "")
|
||||
|
||||
|
||||
def get_task_with_subscription(db: Session, task_id: int) -> ClientServiceTaskInstance | None:
|
||||
return db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.client),
|
||||
joinedload(ClientServiceTaskInstance.subscription).joinedload(ClientServiceSubscription.catalogue),
|
||||
joinedload(ClientServiceTaskInstance.template),
|
||||
)
|
||||
.where(ClientServiceTaskInstance.id == int(task_id))
|
||||
).unique().scalar_one_or_none()
|
||||
|
||||
|
||||
def list_documents_for_task(db: Session, task_id: int) -> list[EngagementDocument]:
|
||||
return db.execute(
|
||||
select(EngagementDocument)
|
||||
.options(joinedload(EngagementDocument.versions), joinedload(EngagementDocument.document_requirement))
|
||||
.where(EngagementDocument.task_instance_id == int(task_id), EngagementDocument.is_deleted.is_(False))
|
||||
.order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc())
|
||||
).unique().scalars().all()
|
||||
|
||||
|
||||
def requirement_upload_status(requirements: list[FirmTaskDocumentRequirement], documents: list[EngagementDocument]) -> list[dict]:
|
||||
by_req: dict[int, list[EngagementDocument]] = {}
|
||||
for doc in documents:
|
||||
if doc.document_requirement_id:
|
||||
by_req.setdefault(int(doc.document_requirement_id), []).append(doc)
|
||||
payload = []
|
||||
for req in requirements:
|
||||
docs = by_req.get(int(req.id), [])
|
||||
payload.append({
|
||||
"requirement": req,
|
||||
"documents": docs,
|
||||
"is_uploaded": bool(docs),
|
||||
"is_pending_mandatory": bool(req.is_mandatory and not docs),
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
def save_uploaded_task_document(
|
||||
db: Session,
|
||||
*,
|
||||
task: ClientServiceTaskInstance,
|
||||
requirement: FirmTaskDocumentRequirement | None,
|
||||
upload_file,
|
||||
title: str,
|
||||
document_type: str,
|
||||
description: str | None,
|
||||
remarks: str | None,
|
||||
user,
|
||||
existing_document_id: int | None = None,
|
||||
) -> EngagementDocument:
|
||||
engagement = task.subscription or db.get(ClientServiceSubscription, task.subscription_id)
|
||||
if engagement is None:
|
||||
raise ValueError("Task is not linked to a valid engagement.")
|
||||
if requirement:
|
||||
title = title or requirement.document_name
|
||||
document_type = requirement.document_type or document_type
|
||||
description = description or requirement.instructions
|
||||
doc = save_uploaded_revision(
|
||||
db,
|
||||
engagement=engagement,
|
||||
upload_file=upload_file,
|
||||
title=title,
|
||||
document_type=document_type,
|
||||
description=description,
|
||||
remarks=remarks,
|
||||
user=user,
|
||||
existing_document_id=existing_document_id,
|
||||
)
|
||||
doc.task_instance_id = task.id
|
||||
doc.document_requirement_id = requirement.id if requirement else None
|
||||
db.flush()
|
||||
return doc
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Bulk Imports</h2>
|
||||
<p class="text-sm text-slate-500">Download Excel templates, fill data, and upload to configure services faster.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
{% if can_client_assignment %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Bulk assign services to clients</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">For Firm Admin / Partner. Services must already be enabled for the active audit firm. Use expiry_date for renewal-before-expiry services like DSC renewal.</p>
|
||||
<a href="/services/bulk-imports/templates/engagement-assignments.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
<form method="post" action="/services/bulk-imports/engagement-assignments" enctype="multipart/form-data" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update existing subscriptions</label>
|
||||
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Assignments</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if can_due_date_extensions %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Import due date extensions</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Imports government deadline extensions for the active audit firm and updates matching unlocked engagements.</p>
|
||||
<a href="/services/bulk-imports/templates/due-date-extensions.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
<form method="post" action="/services/bulk-imports/due-date-extensions" enctype="multipart/form-data" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update duplicate extension rows</label>
|
||||
<p class="text-xs text-slate-500">Multiple extensions are stored as separate history rows. Duplicate rows are detected by service, rule, FY/AY, period, extended date and notification reference.</p>
|
||||
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Extensions</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if can_firm_tasks %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Import firm task templates</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">For Firm Admin. Service must be enabled for active audit firm.</p>
|
||||
<a href="/services/bulk-imports/templates/firm-task-templates.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
<form method="post" action="/services/bulk-imports/firm-task-templates" enctype="multipart/form-data" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update existing sequence rows</label>
|
||||
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Firm Tasks</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if can_system_import %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Import system service master</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">System Admin only. Creates/updates service categories, catalogue services, and optional due date rules/extensions in the workbook.</p>
|
||||
<a href="/services/bulk-imports/templates/service-master.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
<form method="post" action="/services/bulk-imports/service-master" enctype="multipart/form-data" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update existing services/rules</label>
|
||||
<p class="text-xs text-slate-500">Template contains optional sheets: service_master, due_date_rules, due_date_extensions. Due date rules support renewal_based using renewal_days_before_expiry.</p>
|
||||
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Service Master</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Import system default tasks</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">System Admin only. Creates/updates default task templates by service code and sequence no.</p>
|
||||
<a href="/services/bulk-imports/templates/system-default-tasks.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
<form method="post" action="/services/bulk-imports/system-default-tasks" enctype="multipart/form-data" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update existing sequence rows</label>
|
||||
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Default Tasks</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<p class="text-sm text-slate-500">Import summary and row-level validation errors.</p>
|
||||
</div>
|
||||
<a href="{{ back_url }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Created</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.created }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Updated</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.updated }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Skipped</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.skipped }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Errors</div><div class="mt-1 text-2xl font-semibold {% if result.errors %}text-rose-700{% else %}text-emerald-700{% endif %}">{{ result.errors|length }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if result.errors %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-rose-700">Errors found. No rows were committed.</h3>
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Row</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Message</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for err in result.errors %}
|
||||
<tr><td class="px-4 py-3 text-sm text-slate-700">{{ err.row }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ err.message }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-800">Import completed successfully.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ catalogue.service_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ catalogue.service_code }} · {{ catalogue.service_category.name if catalogue.service_category else (catalogue.category or 'Uncategorised') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if can_edit %}<a href="/services/catalogue/{{ catalogue.id }}/edit" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Edit</a>{% endif %}
|
||||
<a href="/services" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Back to Services</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Catalogue Details</h3>
|
||||
<dl class="mt-4 grid gap-3 md:grid-cols-2 text-sm text-slate-700">
|
||||
<div><dt class="font-medium text-slate-500">Recurrence</dt><dd>{{ catalogue.recurrence_type or '-' }}</dd></div>
|
||||
<div><dt class="font-medium text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div>
|
||||
<div><dt class="font-medium text-slate-500">Sort order</dt><dd>{{ catalogue.sort_order }}</dd></div>
|
||||
<div class="md:col-span-2"><dt class="font-medium text-slate-500">Description</dt><dd>{{ catalogue.description or 'No description added.' }}</dd></div>
|
||||
<div class="md:col-span-2"><dt class="font-medium text-slate-500">Applicability</dt><dd>
|
||||
{% set labels = [] %}
|
||||
{% if catalogue.applicable_individual %}{% set _ = labels.append('Individual') %}{% endif %}
|
||||
{% if catalogue.applicable_proprietorship %}{% set _ = labels.append('Proprietorship') %}{% endif %}
|
||||
{% if catalogue.applicable_partnership %}{% set _ = labels.append('Partnership') %}{% endif %}
|
||||
{% if catalogue.applicable_llp %}{% set _ = labels.append('LLP') %}{% endif %}
|
||||
{% if catalogue.applicable_company %}{% set _ = labels.append('Company') %}{% endif %}
|
||||
{% if catalogue.applicable_trust %}{% set _ = labels.append('Trust') %}{% endif %}
|
||||
{% if catalogue.applicable_society %}{% set _ = labels.append('Society') %}{% endif %}
|
||||
{{ labels|join(', ') if labels else '-' }}
|
||||
</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Firm Service Selection</h3>
|
||||
<div class="mt-4 text-sm text-slate-700">
|
||||
{% if current_selection and current_selection.is_enabled %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Selected for Firm</span>
|
||||
<p class="mt-3">Firm task templates configured: {{ current_templates|length }}</p>
|
||||
{% if current_selection.default_branch_id %}<p class="mt-1 text-xs text-slate-500">Default Branch ID: {{ current_selection.default_branch_id }}</p>{% endif %}
|
||||
{% else %}
|
||||
<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Not Selected</span>
|
||||
<p class="mt-3 text-xs text-slate-500">Select this service for the active firm to customise firm task templates.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_manage_firm_services %}
|
||||
<form method="post" action="/services/catalogue/{{ catalogue.id }}/toggle" class="mt-5 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Default Branch</label>
|
||||
{% if branches %}
|
||||
<select name="default_branch_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">No default branch</option>
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch.id }}" {% if current_selection and current_selection.default_branch_id == branch.id %}selected{% endif %}>{{ branch.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="hidden" name="default_branch_id" value="">
|
||||
<p class="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">No active branch found for this firm.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="w-full rounded-xl {% if current_selection and current_selection.is_enabled %}border border-amber-300 text-amber-700 hover:bg-amber-50{% else %}bg-emerald-600 text-white hover:bg-emerald-700{% endif %} px-4 py-2 text-sm font-semibold">
|
||||
{{ 'Disable Service for Firm' if current_selection and current_selection.is_enabled else 'Select Service for Firm' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if current_selection and current_selection.is_enabled %}
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a href="/services/templates/{{ catalogue.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Customise Firm Tasks</a>
|
||||
<a href="/services" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">View Firm Services</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Due Date Rules</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Rules define statutory due dates copied into engagements. Extensions are stored separately as history.</p>
|
||||
</div>
|
||||
{% if can_edit %}<a href="/services/catalogue/{{ catalogue.id }}/due-rules/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Rule</a>{% endif %}
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Rule</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Due Logic</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for rule in due_rules %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ rule.rule_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ rule.period_type|replace('_',' ')|title }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">
|
||||
{% if rule.period_type in ['yearly', 'one_time'] %}
|
||||
{{ '%02d'|format(rule.due_day or 0) }}-{{ '%02d'|format(rule.due_month or 0) }} based on {{ rule.due_year_basis|replace('_',' ') }}
|
||||
{% elif rule.period_type in ['monthly', 'quarterly'] %}
|
||||
Day {{ rule.due_day or '-' }} with month offset {{ rule.due_month_offset }}
|
||||
{% elif rule.period_type == 'renewal_based' %}
|
||||
Expiry date minus {{ rule.renewal_days_before_expiry or 0 }} day(s)
|
||||
{% else %}
|
||||
Manual / event based
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm"><span class="rounded-full px-2 py-1 text-xs font-medium {% if rule.is_active %}bg-emerald-100 text-emerald-700{% else %}bg-slate-200 text-slate-700{% endif %}">{{ 'Active' if rule.is_active else 'Inactive' }}</span></td>
|
||||
<td class="px-4 py-3 text-right">{% if can_edit %}<a href="/services/catalogue/{{ catalogue.id }}/due-rules/{{ rule.id }}/edit" class="text-sm font-medium text-brand-700 hover:underline">Edit</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No due date rules configured yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Due Date Extensions</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Each extension is preserved. New extensions update current due dates for matching unlocked engagements.</p>
|
||||
</div>
|
||||
{% if can_edit %}<a href="/services/catalogue/{{ catalogue.id }}/due-extensions/new" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Add Extension</a>{% endif %}
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">FY / AY / Period</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Previous</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Extended To</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Reference</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for ext in due_extensions %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">FY {{ ext.financial_year }}{% if ext.assessment_year %} / AY {{ ext.assessment_year }}{% endif %}{% if ext.period_label %} / {{ ext.period_label }}{% endif %}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ ext.extension_sequence }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ ext.previous_due_date or '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ ext.extended_due_date }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700"><div>{{ ext.notification_reference or '-' }}</div>{% if ext.notification_date %}<div class="text-xs text-slate-500">{{ ext.notification_date }}</div>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No due date extensions recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between"><h3 class="text-sm font-semibold text-slate-900">System Default Tasks</h3>{% if can_edit %}<a href="/services/catalogue/{{ catalogue.id }}/defaults" class="text-sm font-medium text-brand-700 hover:underline">Manage</a>{% endif %}</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for task in default_templates %}
|
||||
<div class="rounded-xl border border-slate-200 p-4"><div class="text-sm font-semibold text-slate-900">{{ task.sequence_no }}. {{ task.task_name }}</div><div class="mt-1 text-xs text-slate-500">Role: {{ task.default_role_name or '-' }} · Mandatory: {{ 'Yes' if task.is_mandatory else 'No' }} · Review: {{ 'Yes' if task.requires_review else 'No' }}</div>{% if task.description %}<p class="mt-2 text-sm text-slate-700">{{ task.description }}</p>{% endif %}</div>
|
||||
{% else %}<div class="rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No system default tasks configured yet.</div>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-5xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">{% if mode == 'edit' %}Edit Service Catalogue{% else %}Create Service Catalogue{% endif %}</h2>
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Service Code</label><input type="text" name="service_code" value="{{ catalogue.service_code if catalogue else '' }}" {% if mode == 'edit' %}readonly{% endif %} required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm {% if mode == 'edit' %}bg-slate-50{% endif %}"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Service Name</label><input type="text" name="service_name" value="{{ catalogue.service_name if catalogue else '' }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Category</label><select name="category_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select category</option>{% for cat in categories %}<option value="{{ cat.id }}" {% if catalogue and catalogue.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Recurrence Type</label><select name="recurrence_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select recurrence</option>{% for value, label in recurrence_choices %}<option value="{{ value }}" {% if catalogue and catalogue.recurrence_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Engagement Type</label><select name="engagement_type" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{% for value, label in engagement_type_choices %}<option value="{{ value }}" {% if catalogue and catalogue.engagement_type == value %}selected{% elif not catalogue and value == "non_assurance" %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">This value will be copied to client engagements when the service is assigned.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sort Order</label><input type="number" name="sort_order" value="{{ catalogue.sort_order if catalogue else 100 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not catalogue or catalogue.is_active %}checked{% endif %}> Active</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_client_requestable" {% if catalogue and catalogue.is_client_requestable %}checked{% endif %}> Client Requestable</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_consultant_requestable" {% if catalogue and catalogue.is_consultant_requestable %}checked{% endif %}> Consultant Requestable</label></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ catalogue.description if catalogue else '' }}</textarea></div>
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="mb-3 text-sm font-semibold text-slate-900">Applicability</h3>
|
||||
<div class="grid gap-3 md:grid-cols-4 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_individual" {% if catalogue and catalogue.applicable_individual %}checked{% endif %}> Individual</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_proprietorship" {% if catalogue and catalogue.applicable_proprietorship %}checked{% endif %}> Proprietorship</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_partnership" {% if catalogue and catalogue.applicable_partnership %}checked{% endif %}> Partnership</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_llp" {% if catalogue and catalogue.applicable_llp %}checked{% endif %}> LLP</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_company" {% if catalogue and catalogue.applicable_company %}checked{% endif %}> Company</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_trust" {% if catalogue and catalogue.applicable_trust %}checked{% endif %}> Trust</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="applicable_society" {% if catalogue and catalogue.applicable_society %}checked{% endif %}> Society</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-end gap-3"><a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Service Catalogue</h2>
|
||||
<p class="text-sm text-slate-500">Standard service master. Firm Admin can select the services applicable for the active firm.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Firm Services</a>
|
||||
<a href="/services/categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Categories</a>
|
||||
<a href="/services/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Firm Task Templates</a>
|
||||
{% if can_create %}
|
||||
<a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">System Default Tasks</a>
|
||||
<a href="/services/catalogue/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Catalogue Service</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_manage_firm_services %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
Select the services your firm provides. Once selected, you can open <span class="font-semibold">Firm Task Templates</span> and customise tasks for your firm.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-5">
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="Search code, name, category" class="rounded-xl border border-slate-300 px-4 py-2 text-sm md:col-span-2">
|
||||
<select name="category_id" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">All categories</option>
|
||||
{% for cat in categories %}<option value="{{ cat.id }}" {% if category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="recurrence_type" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">All recurrence</option>
|
||||
{% for value, label in recurrence_choices %}<option value="{{ value }}" {% if recurrence_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="engagement_type" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">All engagement types</option>
|
||||
{% for value, label in engagement_type_choices %}<option value="{{ value }}" {% if engagement_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end"><button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium hover:bg-slate-50">Search</button></div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Category</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Recurrence</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Firm Selection</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
{% set selection = firm_selection_by_catalogue.get(row.id) if firm_selection_by_catalogue else None %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.service_code }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">
|
||||
<div class="font-medium text-slate-900">{{ row.service_name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.service_category.name if row.service_category else (row.category or '-') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.recurrence_type|replace('_',' ')|title if row.recurrence_type else '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
{% if selection and selection.is_enabled %}
|
||||
<div class="mb-2"><span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Selected for Firm</span></div>
|
||||
{% if selection.default_branch_id %}<div class="mb-2 text-xs text-slate-500">Default Branch ID: {{ selection.default_branch_id }}</div>{% endif %}
|
||||
{% else %}
|
||||
<div class="mb-2"><span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Not Selected</span></div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_manage_firm_services %}
|
||||
<form method="post" action="/services/catalogue/{{ row.id }}/toggle" class="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if branches %}
|
||||
<select name="default_branch_id" class="max-w-44 rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<option value="">Default branch optional</option>
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch.id }}" {% if selection and selection.default_branch_id == branch.id %}selected{% endif %}>{{ branch.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="hidden" name="default_branch_id" value="">
|
||||
{% endif %}
|
||||
<button class="rounded-lg {% if selection and selection.is_enabled %}border border-amber-300 text-amber-700 hover:bg-amber-50{% else %}bg-emerald-600 text-white hover:bg-emerald-700{% endif %} px-3 py-1.5 text-xs font-semibold">
|
||||
{{ 'Disable for Firm' if selection and selection.is_enabled else 'Select for Firm' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm">
|
||||
<a href="/services/catalogue/{{ row.id }}" class="font-medium text-brand-700 hover:underline">Open</a>
|
||||
{% if selection and selection.is_enabled %}
|
||||
<a href="/services/templates/{{ row.id }}" class="ml-3 font-medium text-brand-700 hover:underline">Firm Tasks</a>
|
||||
{% endif %}
|
||||
{% if can_create %}<a href="/services/catalogue/{{ row.id }}/edit" class="ml-3 font-medium text-brand-700 hover:underline">Edit</a>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">No catalogue services found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">{% if mode == 'edit' %}Edit Service Category{% else %}Create Service Category{% endif %}</h2>
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Category Code</label>
|
||||
<input type="text" name="code" value="{{ category.code if category else '' }}" {% if mode == 'edit' %}readonly{% endif %} required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm {% if mode == 'edit' %}bg-slate-50{% endif %}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Category Name</label>
|
||||
<input type="text" name="name" value="{{ category.name if category else '' }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Sort Order</label>
|
||||
<input type="number" name="sort_order" value="{{ category.sort_order if category else 100 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not category or category.is_active %}checked{% endif %}> Active</label>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-end gap-3">
|
||||
<a href="/services/categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Service Categories</h2>
|
||||
<p class="text-sm text-slate-500">System-level grouping for service catalogue entries.</p>
|
||||
</div>
|
||||
{% if can_create %}<a href="/services/categories/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Category</a>{% endif %}
|
||||
</div>
|
||||
<form method="get" class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="Search code or name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium hover:bg-slate-50">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Name</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Order</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.code }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.sort_order }}</td>
|
||||
<td class="px-4 py-3 text-sm">{% if row.is_active %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/services/categories/{{ row.id }}/edit" class="text-sm font-medium text-brand-700 hover:underline">Edit</a></td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No categories found.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Edit System Default Task</h2>
|
||||
<p class="text-sm text-slate-500">{{ service.service_code }} · {{ service.service_name }}</p>
|
||||
</div>
|
||||
<a href="/services/catalogue/{{ service.id }}/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label>
|
||||
<input type="text" name="task_name" value="{{ task.task_name }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label>
|
||||
<input type="text" name="default_role_name" value="{{ task.default_role_name or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label>
|
||||
<input type="number" min="1" name="sequence_no" value="{{ task.sequence_no }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="is_mandatory" {% if task.is_mandatory %}checked{% endif %}>
|
||||
Mandatory
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="requires_review" {% if task.requires_review %}checked{% endif %}>
|
||||
Review
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="is_active" {% if task.is_active %}checked{% endif %}>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Description</label>
|
||||
<textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ task.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
To deactivate this system default task, untick <strong>Active</strong> and save. Existing firm-copied tasks will not be automatically changed.
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex justify-end gap-3">
|
||||
<a href="/services/catalogue/{{ service.id }}/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ service.service_name }}</h2>
|
||||
<p class="text-sm text-slate-500">System default task templates for {{ service.service_code }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2"><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div>
|
||||
</div>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="space-y-3">
|
||||
{% for task in default_tasks %}
|
||||
{% if task.is_active or is_system_admin %}
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-slate-900">{{ task.sequence_no }}. {{ task.task_name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">
|
||||
Role: {{ task.default_role_name or '-' }} ·
|
||||
Mandatory: {{ 'Yes' if task.is_mandatory else 'No' }} ·
|
||||
Review: {{ 'Yes' if task.requires_review else 'No' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-medium {% if task.is_active %}bg-emerald-100 text-emerald-700{% else %}bg-slate-200 text-slate-700{% endif %}">
|
||||
{{ 'Active' if task.is_active else 'Inactive' }}
|
||||
</span>
|
||||
<a href="/services/catalogue/{{ service.id }}/defaults/{{ task.id }}/edit" class="text-xs font-medium text-brand-700 hover:underline">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if task.description %}
|
||||
<p class="mt-2 text-sm text-slate-700">{{ task.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}<div class="rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No default tasks configured yet.</div>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Add Default Task Template</h3>
|
||||
<form method="post" action="/services/catalogue/{{ service.id }}/defaults/new" class="mt-5 grid gap-4 rounded-2xl border border-slate-200 p-4 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label><input type="text" name="task_name" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label><input type="text" name="default_role_name" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label><input type="number" min="1" name="sequence_no" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_mandatory" checked> Mandatory</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="requires_review"> Review</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" checked> Active</label></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea></div>
|
||||
<div class="md:col-span-2 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Default Task</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Default Task Templates</h2>
|
||||
<p class="text-sm text-slate-500">System-level default task templates by service catalogue.</p>
|
||||
</div>
|
||||
<a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Catalogue</a>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Default Tasks</th><th></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.service_code }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.service_name }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.default_task_templates|length }}</td><td class="px-4 py-3 text-right"><a href="/services/catalogue/{{ row.id }}/defaults" class="text-sm font-medium text-brand-700 hover:underline">Manage</a></td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No catalogue services found.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">Engagement</h2><p class="text-sm text-slate-500">{{ row.client.client_name if row.client else '-' }} · {{ row.catalogue.service_name if row.catalogue else '-' }} · FY {{ row.financial_year }}</p></div>
|
||||
<div class="flex gap-2">
|
||||
{% if can_view_documents(current_user, current_user_permissions, current_user_roles) %}<a href="/documents/engagements/{{ row.id }}" class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">Documents</a>{% endif %}
|
||||
<a href="/services/engagements?financial_year={{ row.financial_year }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if can_manage and not row.is_locked %}<a href="/services/engagements/{{ row.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if row.is_locked %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">This engagement is locked as historical record. It cannot be edited.</div>{% endif %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Period</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Assignment</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Partner</dt><dd>{{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</dd></div><div><dt class="text-slate-500">Manager</dt><dd>{{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</dd></div><div><dt class="text-slate-500">Staff</dt><dd>{{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</dd></div><div><dt class="text-slate-500">Review Partner</dt><dd>{{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</dd></div></dl></section>
|
||||
</div>
|
||||
{% if can_manage and not row.is_locked %}<form method="post" action="/services/engagements/{{ row.id }}/lock" class="rounded-2xl border border-amber-200 bg-white p-5 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="text-sm font-semibold text-slate-900">Year-end Lock</h3><p class="mt-2 text-sm text-slate-600">Lock this engagement when the year is complete. After locking, it becomes read-only history.</p><button class="mt-4 rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Engagement</button></form>{% endif %}
|
||||
{% if row.remarks %}<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Remarks</h3><p class="mt-3 text-sm leading-6 text-slate-700">{{ row.remarks }}</p></section>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Add Due Date Extension</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ catalogue.service_name }} · {{ catalogue.service_code }}</p>
|
||||
</div>
|
||||
<a href="/services/catalogue/{{ catalogue.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Due Date Rule</label>
|
||||
<select name="due_date_rule_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Use first active rule</option>
|
||||
{% for rule in rules %}<option value="{{ rule.id }}">{{ rule.rule_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
|
||||
<input type="text" name="financial_year" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="2025-26">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Assessment Year</label>
|
||||
<input type="text" name="assessment_year" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="2026-27">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Period Label</label>
|
||||
<input type="text" name="period_label" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Apr / Q1 / 2026-04">
|
||||
<p class="mt-1 text-xs text-slate-500">Leave blank for annual services.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Extended Due Date</label>
|
||||
<input type="date" name="extended_due_date" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Notification Date</label>
|
||||
<input type="date" name="notification_date" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Notification / Circular Reference</label>
|
||||
<input type="text" name="notification_reference" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="CBDT Circular / GST Notification reference">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 rounded-xl bg-amber-50 p-4 text-sm text-amber-800">
|
||||
This extension will be stored as a new sequence. Matching unlocked engagements for the active audit firm will be updated to the new current due date. Locked engagements will be skipped.
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-end gap-3">
|
||||
<a href="/services/catalogue/{{ catalogue.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Extension</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{% if mode == 'edit' %}Edit Due Date Rule{% else %}Add Due Date Rule{% endif %}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ catalogue.service_name }} · {{ catalogue.service_code }}</p>
|
||||
</div>
|
||||
<a href="/services/catalogue/{{ catalogue.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Rule Name</label>
|
||||
<input type="text" name="rule_name" value="{{ rule.rule_name if rule else '' }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Tax Audit due date">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Period Type</label>
|
||||
<select name="period_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for value, label in due_period_types %}<option value="{{ value }}" {% if rule and rule.period_type == value %}selected{% elif not rule and value == 'yearly' %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Due Year Basis</label>
|
||||
<select name="due_year_basis" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for value, label in due_year_basis_choices %}<option value="{{ value }}" {% if rule and rule.due_year_basis == value %}selected{% elif not rule and value == 'assessment_year_start' %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">For Tax Audit AY 2026-27 due 30 Sep 2026, use assessment year start year.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Due Day</label>
|
||||
<input type="number" min="1" max="31" name="due_day" value="{{ rule.due_day if rule and rule.due_day else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="30">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Due Month</label>
|
||||
<input type="number" min="1" max="12" name="due_month" value="{{ rule.due_month if rule and rule.due_month else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="9">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Due Month Offset</label>
|
||||
<input type="number" name="due_month_offset" value="{{ rule.due_month_offset if rule else 0 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">For monthly GST 20th of next month, use period type Monthly, due day 20, offset 1.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Days After Event</label>
|
||||
<input type="number" name="days_offset_after_event" value="{{ rule.days_offset_after_event if rule and rule.days_offset_after_event is not none else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="30">
|
||||
<p class="mt-1 text-xs text-slate-500">For event-based rules like 30 days from AGM. Event-date calculation can be handled later.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Renewal Days Before Expiry</label>
|
||||
<input type="number" min="0" name="renewal_days_before_expiry" value="{{ rule.renewal_days_before_expiry if rule and rule.renewal_days_before_expiry is not none else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="30">
|
||||
<p class="mt-1 text-xs text-slate-500">For renewal-before-expiry services like DSC/FSSAI. Due date = engagement expiry date minus these days.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Sort Order</label>
|
||||
<input type="number" name="sort_order" value="{{ rule.sort_order if rule else 100 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-7 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not rule or rule.is_active %}checked{% endif %}> Active</label>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ rule.remarks if rule else '' }}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-end gap-3">
|
||||
<a href="/services/catalogue/{{ catalogue.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">Engagement</h2><p class="text-sm text-slate-500">{{ row.client.client_name if row.client else '-' }} · {{ row.catalogue.service_name if row.catalogue else '-' }} · FY {{ row.financial_year }}</p></div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/services/engagements?financial_year={{ row.financial_year }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if can_manage and not row.is_locked %}<a href="/services/engagements/{{ row.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if row.is_locked %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">This engagement is locked as historical record. It cannot be edited.</div>{% endif %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Period</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Assignment</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Partner</dt><dd>{{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</dd></div><div><dt class="text-slate-500">Manager</dt><dd>{{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</dd></div><div><dt class="text-slate-500">Staff</dt><dd>{{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</dd></div><div><dt class="text-slate-500">Review Partner</dt><dd>{{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</dd></div></dl></section>
|
||||
</div>
|
||||
{% if can_manage and not row.is_locked %}<form method="post" action="/services/engagements/{{ row.id }}/lock" class="rounded-2xl border border-amber-200 bg-white p-5 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="text-sm font-semibold text-slate-900">Year-end Lock</h3><p class="mt-2 text-sm text-slate-600">Lock this engagement when the year is complete. After locking, it becomes read-only history.</p><button class="mt-4 rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Engagement</button></form>{% endif %}
|
||||
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Engagement Tasks & Task Documents</h3>
|
||||
<p class="text-sm text-slate-500">Open each task to upload documents against its configured document requirements.</p>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Seq</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Documents</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for task in tasks or [] %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ task.sequence_no }}</td>
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ task.task_name }}</div>{% if task.description %}<div class="text-xs text-slate-500">{{ task.description }}</div>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ task.status.replace('_',' ').title() }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/documents/tasks/{{ task.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open Task Documents</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No execution tasks generated yet. Generate work tracker tasks first.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% if row.remarks %}<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Remarks</h3><p class="mt-3 text-sm leading-6 text-slate-700">{{ row.remarks }}</p></section>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{% if mode == 'edit' %}Edit Engagement{% else %}Assign Service to Client{% endif %}</h2>
|
||||
<p class="text-sm text-slate-500">Engagements are maintained financial-year wise. Review partner is used only for assurance engagements of partnership audit firms.</p>
|
||||
</div>
|
||||
<a href="/services/engagements" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
|
||||
{% if mode == 'edit' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700">{{ subscription.financial_year }}</div>
|
||||
{% else %}
|
||||
<input type="text" name="financial_year" value="{{ financial_year or '2025-26' }}" required placeholder="2025-26" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Client</label>
|
||||
{% if mode == 'edit' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700">{{ subscription.client.client_name if subscription and subscription.client else '-' }}</div>
|
||||
{% else %}
|
||||
<select name="client_id" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client_id and client.id == selected_client_id %}selected{% endif %}>{{ client.client_name }} ({{ client.client_code }})</option>{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Enabled Service</label>
|
||||
{% if mode == 'edit' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700">
|
||||
{{ subscription.catalogue.service_name if subscription and subscription.catalogue else '-' }} · {{ 'Assurance' if subscription and subscription.engagement_type == 'assurance' else 'Non-Assurance' }}
|
||||
</div>
|
||||
{% else %}
|
||||
<select name="service_catalogue_id" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Select service</option>
|
||||
{% for selection in enabled_services %}
|
||||
<option value="{{ selection.catalogue.id }}">{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }}) - {{ 'Assurance' if selection.catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Partner</label><select name="assigned_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_partner_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Manager</label><select name="assigned_manager_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_manager_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Staff</label><select name="assigned_staff_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_staff_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Review Partner</label><select name="review_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Use client default / Not required</option>{% for u in review_partners or [] %}<option value="{{ u.id }}" {% if subscription and subscription.review_partner_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Saved only for assurance engagements of partnership audit firms.</p></div>
|
||||
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Status</label><select name="status" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{% for value, label in subscription_statuses %}<option value="{{ value }}" {% if subscription and subscription.status == value %}selected{% elif not subscription and value == 'active' %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Start Date</label><input type="date" name="start_date" value="{{ subscription.start_date if subscription and subscription.start_date else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">End Date</label><input type="date" name="end_date" value="{{ subscription.end_date if subscription and subscription.end_date else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Expiry Date</label><input type="date" name="expiry_date" value="{{ subscription.expiry_date if subscription and subscription.expiry_date else '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><p class="mt-1 text-xs text-slate-500">Use for renewal-before-expiry services like DSC/FSSAI. Current due date is calculated from the service due-date rule.</p></div>
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not subscription or subscription.is_active %}checked{% endif %}> Active</label></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Remarks</label><textarea name="remarks" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ subscription.remarks if subscription and subscription.remarks else '' }}</textarea></div>
|
||||
<div class="md:col-span-2 flex justify-end gap-3"><a href="/services/engagements" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Engagements</h2>
|
||||
<p class="text-sm text-slate-500">Year-wise client service engagements with locking for completed years.</p>
|
||||
</div>
|
||||
{% if can_manage %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services/bulk-imports" class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Bulk Assign by Excel</a>
|
||||
<a href="/services/engagements/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Assign Service</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if locked_count or skipped_count %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-soft">
|
||||
{% if locked_count %}<span class="font-medium text-emerald-700">{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.</span>{% endif %}
|
||||
{% if skipped_count %}<span class="ml-2 font-medium text-amber-700">{{ skipped_count }} skipped because already locked or not permitted.</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="flex flex-wrap items-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</label>
|
||||
<input type="text" name="financial_year" value="{{ financial_year or '' }}" placeholder="2025-26" class="w-36 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||
<input type="text" name="q" value="{{ q or '' }}" placeholder="Client or service" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="true" {% if include_inactive %}checked{% endif %}>
|
||||
Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/services/engagements/bulk-lock" class="space-y-3" onsubmit="return confirm('Lock selected engagements? Locked engagements become read-only history.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="financial_year" value="{{ financial_year or '' }}">
|
||||
<input type="hidden" name="q" value="{{ q or '' }}">
|
||||
{% if include_inactive %}<input type="hidden" name="include_inactive" value="true">{% endif %}
|
||||
{% if can_lock_engagements %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<p class="text-sm text-slate-600">Select completed engagements and lock them in bulk. Locked engagements become read-only history.</p>
|
||||
<button type="submit" class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Selected Engagements</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
{% if can_lock_engagements %}<th class="w-10 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><input type="checkbox" onclick="document.querySelectorAll('.engagement-lock-checkbox').forEach(cb => cb.checked = this.checked)"></th>{% endif %}
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">FY / AY</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Due Date</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Assigned Users</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% if can_lock_engagements %}<td class="px-4 py-3 text-sm">{% if not row.is_locked %}<input type="checkbox" class="engagement-lock-checkbox rounded border-slate-300" name="subscription_ids" value="{{ row.id }}">{% endif %}</td>{% endif %}
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</div><div class="text-xs text-slate-500">{{ row.client.client_code if row.client else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</div><div class="text-xs text-slate-500">{{ row.catalogue.service_code if row.catalogue else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>Current: <span class="font-medium text-slate-900">{{ row.current_due_date or '-' }}</span></div><div>Original: {{ row.original_due_date or '-' }}</div>{% if row.expiry_date %}<div>Expiry: {{ row.expiry_date }}</div>{% endif %}{% if row.due_date_source %}<div class="text-slate-500">{{ row.due_date_source|replace('_',' ')|title }}</div>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</div><div>Manager: {{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</div><div>Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</div><div>Review: {{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</div></td>
|
||||
<td class="px-4 py-3 text-sm"><span class="rounded-full px-2 py-1 text-xs font-medium {% if row.is_locked %}bg-amber-100 text-amber-700{% elif row.is_active and row.status == 'active' %}bg-emerald-100 text-emerald-700{% else %}bg-slate-200 text-slate-700{% endif %}">{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</span></td>
|
||||
<td class="px-4 py-3 text-right text-sm"><a href="/services/engagements/{{ row.id }}" class="font-medium text-brand-700 hover:underline">View</a>{% if can_manage and not row.is_locked %}<a href="/services/engagements/{{ row.id }}/edit" class="ml-3 font-medium text-brand-700 hover:underline">Edit</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ 9 if can_lock_engagements else 8 }}" class="px-4 py-8 text-center text-sm text-slate-500">No engagements found for this financial year.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Edit Firm Task Template</h2>
|
||||
<p class="text-sm text-slate-500">{{ service.service_code }} · {{ service.service_name }}</p>
|
||||
</div>
|
||||
<a href="/services/templates/{{ service.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Task Name</label>
|
||||
<input type="text" name="task_name" value="{{ task.task_name }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Role</label>
|
||||
<input type="text" name="default_role_name" value="{{ task.default_role_name or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Sequence No</label>
|
||||
<input type="number" min="1" name="sequence_no" value="{{ task.sequence_no }}" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="is_mandatory" {% if task.is_mandatory %}checked{% endif %}>
|
||||
Mandatory
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="requires_review" {% if task.requires_review %}checked{% endif %}>
|
||||
Review
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="is_active" {% if task.is_active %}checked{% endif %}>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Description</label>
|
||||
<textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ task.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
To deactivate this task, untick <strong>Active</strong> and save. No hard delete is used.
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex justify-end gap-3">
|
||||
<a href="/services/templates/{{ service.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl space-y-6"><div class="rounded-3xl bg-white p-6 shadow-soft"><h2 class="text-xl font-semibold text-slate-900">Import Service Catalogue + Firm Task Templates</h2><p class="mt-2 text-sm text-slate-500">This import updates the system-level service catalogue and creates or updates task templates for your current firm.</p><div class="mt-4 flex gap-3"><a href="/services/catalogue/import/template" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a><a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Catalogue</a></div><form method="post" action="/services/catalogue/import/preview" enctype="multipart/form-data" class="mt-6 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div><label class="mb-2 block text-sm font-medium text-slate-700">Upload Filled Workbook (.xlsx)</label><input type="file" name="workbook" accept=".xlsx" required class="block w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm"></div><div class="flex gap-3"><button class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Preview Import</button><a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a></div></form></div></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6"><div class="rounded-3xl bg-white p-6 shadow-soft"><h2 class="text-xl font-semibold text-slate-900">Import Preview</h2><p class="mt-2 text-sm text-slate-500">Audit Firm ID {{ tenant_id }} • Branch {{ branch_id }}</p>{% if preview.errors %}<div class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900"><div class="font-medium">Please fix these issues before importing:</div><ul class="mt-2 list-disc pl-5">{% for err in preview.errors %}<li>{{ err }}</li>{% endfor %}</ul></div><div class="mt-4"><a href="/services/catalogue/import" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a></div>{% else %}<div class="grid gap-4 md:grid-cols-2"><div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase text-slate-500">Catalogue Rows</div><div class="mt-1 text-xl font-semibold text-slate-900">{{ preview.catalogue_rows|length }}</div></div><div class="rounded-2xl bg-slate-50 p-4"><div class="text-xs uppercase text-slate-500">Task Template Rows</div><div class="mt-1 text-xl font-semibold text-slate-900">{{ preview.task_rows|length }}</div></div></div><div class="mt-6 rounded-2xl border border-slate-200"><div class="border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700">Catalogue Preview</div><div class="overflow-x-auto"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-white"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Name</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Category</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in preview.catalogue_rows[:15] %}<tr><td class="px-4 py-3 text-sm">{{ row.service_code }}</td><td class="px-4 py-3 text-sm">{{ row.service_name }}</td><td class="px-4 py-3 text-sm">{{ row.category or '-' }}</td></tr>{% endfor %}</tbody></table></div></div>{% if preview.task_rows %}<div class="rounded-2xl border border-slate-200"><div class="border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700">Firm Task Template Preview</div><div class="overflow-x-auto"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-white"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Seq</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in preview.task_rows[:20] %}<tr><td class="px-4 py-3 text-sm">{{ row.service_code }}</td><td class="px-4 py-3 text-sm">{{ row.sequence_no }}</td><td class="px-4 py-3 text-sm">{{ row.task_name }}</td></tr>{% endfor %}</tbody></table></div></div>{% endif %}<form method="post" action="/services/catalogue/import/confirm" class="flex gap-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="payload_json" value="{{ payload_json|e }}"><button class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Confirm Import</button><a href="/services/catalogue/import" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a></form>{% endif %}</div></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Engagements</h2>
|
||||
<p class="text-sm text-slate-500">Year-wise client service engagements with locking for completed years.</p>
|
||||
</div>
|
||||
{% if can_manage %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services/bulk-imports" class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Bulk Assign by Excel</a>
|
||||
<a href="/services/engagements/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Assign Service</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if locked_count or skipped_count %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-soft">
|
||||
{% if locked_count %}<span class="font-medium text-emerald-700">{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.</span>{% endif %}
|
||||
{% if skipped_count %}<span class="ml-2 font-medium text-amber-700">{{ skipped_count }} skipped because already locked or not permitted.</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="flex flex-wrap items-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</label>
|
||||
<input type="text" name="financial_year" value="{{ financial_year or '' }}" placeholder="2025-26" class="w-36 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||
<input type="text" name="q" value="{{ q or '' }}" placeholder="Client or service" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="true" {% if include_inactive %}checked{% endif %}>
|
||||
Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/services/engagements/bulk-lock" class="space-y-3" onsubmit="return confirm('Lock selected engagements? Locked engagements become read-only history.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="financial_year" value="{{ financial_year or '' }}">
|
||||
<input type="hidden" name="q" value="{{ q or '' }}">
|
||||
{% if include_inactive %}<input type="hidden" name="include_inactive" value="true">{% endif %}
|
||||
{% if can_lock_engagements %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<p class="text-sm text-slate-600">Select completed engagements and lock them in bulk. Locked engagements become read-only history.</p>
|
||||
<button type="submit" class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Selected Engagements</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
{% if can_lock_engagements %}<th class="w-10 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><input type="checkbox" onclick="document.querySelectorAll('.engagement-lock-checkbox').forEach(cb => cb.checked = this.checked)"></th>{% endif %}
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">FY / AY</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Due Date</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Assigned Users</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% if can_lock_engagements %}<td class="px-4 py-3 text-sm">{% if not row.is_locked %}<input type="checkbox" class="engagement-lock-checkbox rounded border-slate-300" name="subscription_ids" value="{{ row.id }}">{% endif %}</td>{% endif %}
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</div><div class="text-xs text-slate-500">{{ row.client.client_code if row.client else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</div><div class="text-xs text-slate-500">{{ row.catalogue.service_code if row.catalogue else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>Current: <span class="font-medium text-slate-900">{{ row.current_due_date or '-' }}</span></div><div>Original: {{ row.original_due_date or '-' }}</div>{% if row.expiry_date %}<div>Expiry: {{ row.expiry_date }}</div>{% endif %}{% if row.due_date_source %}<div class="text-slate-500">{{ row.due_date_source|replace('_',' ')|title }}</div>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</div><div>Manager: {{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</div><div>Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</div><div>Review: {{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</div></td>
|
||||
<td class="px-4 py-3 text-sm"><span class="rounded-full px-2 py-1 text-xs font-medium {% if row.is_locked %}bg-amber-100 text-amber-700{% elif row.is_active and row.status == 'active' %}bg-emerald-100 text-emerald-700{% else %}bg-slate-200 text-slate-700{% endif %}">{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</span></td>
|
||||
<td class="px-4 py-3 text-right text-sm"><a href="/services/engagements/{{ row.id }}" class="font-medium text-brand-700 hover:underline">View</a>{% if can_view_documents(current_user, current_user_permissions, current_user_roles) %}<a href="/documents/engagements/{{ row.id }}" class="ml-3 font-medium text-brand-700 hover:underline">Docs</a>{% endif %}{% if can_manage and not row.is_locked %}<a href="/services/engagements/{{ row.id }}/edit" class="ml-3 font-medium text-brand-700 hover:underline">Edit</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ 9 if can_lock_engagements else 8 }}" class="px-4 py-8 text-center text-sm text-slate-500">No engagements found for this financial year.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,181 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Firm Task Templates</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ service.service_code }} · {{ service.service_name }}
|
||||
{% if selection and selection.default_branch_id %} · Default Branch ID {{ selection.default_branch_id }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/services/templates" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Templates</a>
|
||||
<a href="/services/catalogue/{{ service.id }}" class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">View Catalogue</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('requirement_added') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document requirement added successfully.</div>{% endif %}
|
||||
{% if request.query_params.get('template_uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Template file uploaded successfully.</div>{% endif %}
|
||||
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check the selected task, file and permissions.</div>{% endif %}
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Firm Status</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ 'Enabled' if selection and selection.is_enabled else 'Not Enabled' }}</div>
|
||||
<p class="mt-1 text-sm text-slate-500">Firm-level service selection controls whether these task templates are used.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Firm Tasks</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ task_templates|length }}</div>
|
||||
<p class="mt-1 text-sm text-slate-500">Tasks customised for the active firm.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">System Defaults</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ default_tasks|length }}</div>
|
||||
<p class="mt-1 text-sm text-slate-500">Defaults may be copied and customised for the firm.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if can_manage_tasks %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Add Firm Task</h3>
|
||||
<p class="text-sm text-slate-500">Create a task template for this firm and service.</p>
|
||||
</div>
|
||||
{% if default_tasks|length > 0 %}
|
||||
<form method="post" action="/services/catalogue/{{ service.id }}/defaults/copy-to-firm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50" type="submit">Copy System Defaults</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/services/templates/{{ service.id }}/tasks/new" class="mt-5 grid gap-4 lg:grid-cols-12">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="lg:col-span-4">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Task Name</label>
|
||||
<input name="task_name" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="e.g. Verify books with GSTR-2B">
|
||||
</div>
|
||||
<div class="lg:col-span-3">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Default Role</label>
|
||||
<input name="default_role_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Staff / Manager / Partner">
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Sequence</label>
|
||||
<input name="sequence_no" type="number" min="1" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Auto">
|
||||
</div>
|
||||
<div class="lg:col-span-3">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Options</label>
|
||||
<div class="flex flex-wrap gap-3 rounded-xl border border-slate-200 px-3 py-2 text-xs text-slate-700">
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_mandatory" checked> Mandatory</label>
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" name="requires_review"> Review</label>
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-10">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Description</label>
|
||||
<textarea name="description" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional instructions for staff or reviewer"></textarea>
|
||||
</div>
|
||||
<div class="flex items-end lg:col-span-2">
|
||||
<button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700" type="submit">Add Task</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Firm Task List</h3>
|
||||
<p class="text-sm text-slate-500">These tasks will be used when work is generated for this firm.</p>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Seq</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Role</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Flags</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Document Requirements</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Template Uploads</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for task in task_templates %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ task.sequence_no }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">
|
||||
<div class="font-medium text-slate-900">{{ task.task_name }}</div>
|
||||
{% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ task.default_role_name or '-' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% if task.is_mandatory %}<span class="rounded-full bg-slate-100 px-2 py-1">Mandatory</span>{% endif %}
|
||||
{% if task.requires_review %}<span class="rounded-full bg-amber-100 px-2 py-1 text-amber-800">Review</span>{% endif %}
|
||||
<span class="rounded-full px-2 py-1 {{ 'bg-emerald-100 text-emerald-800' if task.is_active else 'bg-slate-100 text-slate-500' }}">{{ 'Active' if task.is_active else 'Inactive' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600 align-top">
|
||||
{% set reqs = task_requirement_map.get(task.id, []) if task_requirement_map else [] %}
|
||||
<div class="space-y-2">
|
||||
{% for req in reqs %}
|
||||
<div class="rounded-lg border border-slate-200 p-2">
|
||||
<div class="font-medium text-slate-800">{{ req.document_name }}</div>
|
||||
<div class="text-[11px] text-slate-500">{{ req.document_type.replace('_',' ').title() }} · {{ 'Mandatory' if req.is_mandatory else 'Optional' }} · {{ 'Active' if req.is_active else 'Inactive' }}</div>
|
||||
{% if can_manage_tasks %}
|
||||
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/document-requirements/{{ req.id }}/toggle" class="mt-1">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="text-[11px] font-semibold text-brand-700">{{ 'Deactivate' if req.is_active else 'Activate' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}<span class="text-slate-400">No requirements</span>{% endfor %}
|
||||
</div>
|
||||
{% if can_manage_tasks %}
|
||||
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/document-requirements/new" class="mt-3 space-y-2 rounded-xl bg-slate-50 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="document_name" required placeholder="Document name" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input name="document_type" value="GENERAL" placeholder="Type" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<input name="sort_order" type="number" value="100" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
</div>
|
||||
<input name="allowed_file_types" placeholder="Allowed: pdf,docx,xlsx" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<textarea name="instructions" rows="2" placeholder="Instructions" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<label class="inline-flex items-center gap-1 text-[11px]"><input type="checkbox" name="is_mandatory" checked> Mandatory</label>
|
||||
<button class="block rounded-lg bg-slate-900 px-3 py-1 text-xs font-semibold text-white">Add Requirement</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600 align-top">
|
||||
{% set files = task_template_file_map.get(task.id, []) if task_template_file_map else [] %}
|
||||
<div class="space-y-1">
|
||||
{% for tpl in files %}
|
||||
<div><a href="/services/document-templates/{{ tpl.id }}/download" class="font-medium text-brand-700 hover:underline">{{ tpl.template_name }}</a><div class="text-[11px] text-slate-500">{{ tpl.template_category or 'Template' }} · {{ tpl.original_filename }}</div></div>
|
||||
{% else %}<span class="text-slate-400">No template files</span>{% endfor %}
|
||||
</div>
|
||||
{% if can_manage_tasks %}
|
||||
<form method="post" action="/services/templates/{{ service.id }}/tasks/{{ task.id }}/document-templates/upload" enctype="multipart/form-data" class="mt-3 space-y-2 rounded-xl bg-slate-50 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="template_name" placeholder="Template name" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<input name="template_category" placeholder="NOC / Agreement / Deed" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<input type="file" name="file" required class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<textarea name="description" rows="2" placeholder="Description" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
|
||||
<button class="block rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-white">Upload Template</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right align-top">
|
||||
{% if can_manage_tasks %}<a href="/services/templates/{{ service.id }}/tasks/{{ task.id }}/edit" class="text-sm font-medium text-brand-700 hover:underline">Edit</a>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No firm task templates yet. Copy defaults or add tasks manually.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Firm Service Task Templates</h2>
|
||||
<p class="text-sm text-slate-500">Each firm can maintain its own execution template for enabled services.</p>
|
||||
</div>
|
||||
<a href="/services" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Services</a>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task Count</th>
|
||||
<th class="px-4 py-3"></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.catalogue.service_code }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.catalogue.service_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.task_count }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/services/templates/{{ row.catalogue.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">Enable a service for the firm first.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Work Tracker Dashboard</h2>
|
||||
<p class="text-sm text-slate-500">Track engagement tasks, assigned staff, internal target dates and execution status.</p>
|
||||
</div>
|
||||
<a href="/services/work-tracker/subscriptions" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Generate Tasks</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4 xl:grid-cols-8">
|
||||
<a href="/services/work-tracker" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-slate-50"><div class="text-xs text-slate-500">Total</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ stats.total }}</div></a>
|
||||
<a href="/services/work-tracker?status=pending" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-slate-50"><div class="text-xs text-slate-500">Pending</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ stats.pending }}</div></a>
|
||||
<a href="/services/work-tracker?status=in_progress" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-slate-50"><div class="text-xs text-slate-500">In Progress</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ stats.in_progress }}</div></a>
|
||||
<a href="/services/work-tracker?status=blocked" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-slate-50"><div class="text-xs text-slate-500">Blocked</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ stats.blocked }}</div></a>
|
||||
<a href="/services/work-tracker?status=overdue" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-red-50"><div class="text-xs text-slate-500">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ stats.overdue }}</div></a>
|
||||
<a href="/services/work-tracker?status=due_today" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-amber-50"><div class="text-xs text-slate-500">Due Today</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ stats.due_today }}</div></a>
|
||||
<a href="/services/work-tracker?status=unassigned" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-slate-50"><div class="text-xs text-slate-500">Unassigned</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ stats.unassigned }}</div></a>
|
||||
<a href="/services/work-tracker?status=completed" class="rounded-2xl bg-white p-4 shadow-soft hover:bg-green-50"><div class="text-xs text-slate-500">Completed</div><div class="mt-1 text-2xl font-semibold text-green-700">{{ stats.completed }}</div></a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="flex flex-wrap items-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Client / service / task">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Status / Filter</label>
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
<option value="overdue" {% if status == 'overdue' %}selected{% endif %}>Overdue</option>
|
||||
<option value="due_today" {% if status == 'due_today' %}selected{% endif %}>Due Today</option>
|
||||
<option value="unassigned" {% if status == 'unassigned' %}selected{% endif %}>Unassigned</option>
|
||||
{% for code, label in task_statuses %}
|
||||
<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
{% if q or status %}<a href="/services/work-tracker" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Clear</a>{% endif %}
|
||||
</form>
|
||||
|
||||
<form method="post" action="/services/work-tracker/tasks/bulk-update" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if can_bulk_manage %}
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="min-w-44">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Bulk Status</label>
|
||||
<select name="bulk_status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">No change</option>
|
||||
{% for code, label in task_statuses %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if can_assign_staff %}
|
||||
<div class="min-w-56">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Bulk Assignee</label>
|
||||
<select name="bulk_assigned_to_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="__no_change__">No change</option>
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in assignees %}<option value="{{ user.id }}">{{ user.full_name or user.email }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Internal Target Date</label>
|
||||
<input type="date" name="bulk_internal_target_date" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<label class="mt-2 flex items-center gap-2 text-xs text-slate-600"><input type="checkbox" name="update_internal_target_date"> Apply target date</label>
|
||||
</div>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700" onclick="return confirm('Update selected tasks?')">Update Selected Tasks</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-slate-500">Select tasks below, then apply status, assignee or internal target date. Locked engagements/tasks are skipped.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
{% if can_bulk_manage %}<th class="px-4 py-3 text-left"><input type="checkbox" onclick="document.querySelectorAll('.task-check').forEach(cb => cb.checked = this.checked)"></th>{% endif %}
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Task</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Assignee</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Internal Target</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Engagement Due</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for task in tasks %}
|
||||
<tr class="{% if task.is_task_overdue %}bg-red-50{% elif task.is_due_today %}bg-amber-50{% endif %}">
|
||||
{% if can_bulk_manage %}<td class="px-4 py-3"><input type="checkbox" class="task-check" name="task_ids" value="{{ task.id }}"></td>{% endif %}
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-900">{{ task.sequence_no }}. {{ task.task_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ task.client.client_name if task.client else '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ task.catalogue.service_name if task.catalogue else '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ task.assigned_to.full_name if task.assigned_to and task.assigned_to.full_name else (task.assigned_to.email if task.assigned_to else '-') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">
|
||||
{% if task.internal_target_date %}
|
||||
<span class="{% if task.is_task_overdue %}font-semibold text-red-700{% elif task.is_due_today %}font-semibold text-amber-700{% endif %}">{{ task.internal_target_date }}</span>
|
||||
{% if task.is_task_overdue %}<span class="ml-2 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">Overdue</span>{% endif %}
|
||||
{% if task.is_due_today %}<span class="ml-2 rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">Today</span>{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{% if task.subscription and task.subscription.current_due_date %}{{ task.subscription.current_due_date }}{% else %}-{% endif %}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">{{ task.tracker_status_label }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><a href="/services/work-tracker/tasks/{{ task.id }}/edit" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{% if can_bulk_manage %}9{% else %}8{% endif %}" class="px-4 py-8 text-center text-sm text-slate-500">No execution tasks found. Generate tasks from subscriptions first.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Generate Service Tasks</h2>
|
||||
<p class="text-sm text-slate-500">Generate execution tasks from firm task templates for active engagement subscriptions.</p>
|
||||
</div>
|
||||
<a href="/services/work-tracker" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Dashboard</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl bg-white p-4 shadow-soft">
|
||||
<input type="text" name="q" value="{{ q }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Search client or service">
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Tasks</th><th></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm text-slate-900">{{ row.subscription.client.client_name if row.subscription.client else '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.subscription.catalogue.service_name if row.subscription.catalogue else '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ row.completed_tasks }}/{{ row.total_tasks }} completed · {{ row.open_tasks }} open</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if can_generate %}
|
||||
<form method="post" action="/services/work-tracker/subscriptions/{{ row.subscription.id }}/generate" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl bg-brand-600 px-3 py-2 text-xs font-medium text-white hover:bg-brand-700">Generate / Sync Tasks</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No active subscriptions found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Update Service Task</h2>
|
||||
<p class="text-sm text-slate-500">{{ task.client.client_name if task.client else '-' }} · {{ task.catalogue.service_name if task.catalogue else '-' }}</p>
|
||||
</div>
|
||||
<a href="/services/work-tracker" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ task.sequence_no }}. {{ task.task_name }}</div>
|
||||
{% if task.description %}<p class="mt-2 text-sm text-slate-700">{{ task.description }}</p>{% endif %}
|
||||
<div class="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2">
|
||||
<div><span class="font-medium text-slate-700">Engagement Due Date:</span> {{ task.subscription.current_due_date if task.subscription and task.subscription.current_due_date else '-' }}</div>
|
||||
<div><span class="font-medium text-slate-700">Internal Target Date:</span> {{ task.internal_target_date or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.is_task_overdue %}
|
||||
<div class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">This task is overdue based on the internal target date.</div>
|
||||
{% elif task.is_due_today %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">This task is due today based on the internal target date.</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6 grid gap-5 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Status</label>
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not can_edit %}disabled{% endif %}>
|
||||
{% for code, label in task_statuses %}<option value="{{ code }}" {% if task.status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Priority</label>
|
||||
<select name="priority" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not can_manage_fields %}disabled{% endif %}>
|
||||
{% for code, label in task_priorities %}<option value="{{ code }}" {% if task.priority == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Internal Target Date</label>
|
||||
<input type="date" name="internal_target_date" value="{{ task.internal_target_date or '' }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not can_manage_fields %}readonly{% endif %}>
|
||||
<p class="mt-1 text-xs text-slate-500">Internal office target date. Statutory due date remains at engagement level.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Assigned To</label>
|
||||
<select name="assigned_to_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not can_reassign %}disabled{% endif %}>
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in assignees %}<option value="{{ user.id }}" {% if task.assigned_to_user_id == user.id %}selected{% endif %}>{{ user.full_name or user.email }}</option>{% endfor %}
|
||||
</select>
|
||||
{% if not can_reassign and task.assigned_to %}<p class="mt-1 text-xs text-slate-500">Assignee changes are restricted for this role.</p>{% endif %}
|
||||
{% if can_edit and not can_manage_fields %}<p class="mt-1 text-xs text-slate-500">You can update status and work note only for your own assigned task.</p>{% endif %}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Remarks / Work Note</label>
|
||||
<textarea name="remarks" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not can_edit %}readonly{% endif %}>{{ task.remarks or '' }}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="is_active" {% if task.is_active %}checked{% endif %} {% if not can_manage_fields %}disabled{% endif %}> Active</label>
|
||||
<div class="flex gap-3">
|
||||
<a href="/services/work-tracker" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
{% if can_edit %}<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-6 max-w-3xl rounded-2xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Task Communication Timeline</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Record internal notes, client clarifications, consultant clarifications, and partner review notes for this task.</p>
|
||||
</div>
|
||||
{% if task.subscription and task.subscription.is_locked %}
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">Locked</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_add_comment and comment_type_options %}
|
||||
<form method="post" action="/services/work-tracker/tasks/{{ task.id }}/comments" class="mt-5 grid gap-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Comment Type</label>
|
||||
<select name="comment_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for code, label in comment_type_options %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Visibility</label>
|
||||
<select name="visibility" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for code, label in task_comment_visibilities %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Client/consultant visibility is stored now and will be used when portals are enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Message</label>
|
||||
<textarea name="message" rows="4" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type the communication note here..."></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Add Communication</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">Communication entry is not available for this task or role.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
{% if comments %}
|
||||
{% for comment in comments %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% set type_label = comment.comment_type.replace('_', ' ').title() %}
|
||||
{% set visibility_label = comment.visibility.replace('_', ' ').title() %}
|
||||
<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-medium text-white">{{ type_label }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{{ visibility_label }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ comment.created_at_utc }}</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-slate-700">{{ comment.created_by.full_name or comment.created_by.email if comment.created_by else 'System' }}</div>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{{ comment.message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">No task communication recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
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.core.iam.models import User
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.services.execution import (
|
||||
TASK_COMMENT_TYPES,
|
||||
TASK_COMMENT_VISIBILITIES,
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
add_task_comment,
|
||||
apply_bulk_task_update,
|
||||
apply_task_update,
|
||||
dashboard_stats,
|
||||
generate_tasks_for_subscription,
|
||||
get_subscription_for_execution,
|
||||
get_task,
|
||||
get_tasks_for_bulk_update,
|
||||
list_assignees_for_execution,
|
||||
list_task_comments,
|
||||
list_subscription_execution_payload,
|
||||
list_tasks_payload,
|
||||
parse_date_value,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/services/work-tracker", tags=["services-work-tracker-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **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),
|
||||
"task_statuses": TASK_STATUSES,
|
||||
"task_priorities": TASK_PRIORITIES,
|
||||
"task_comment_types": TASK_COMMENT_TYPES,
|
||||
"task_comment_visibilities": TASK_COMMENT_VISIBILITIES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def _role_names(db, user) -> set[str]:
|
||||
return set(get_user_roles(db, user.id))
|
||||
|
||||
|
||||
def _is_firm_admin(db, user) -> bool:
|
||||
return "Firm Admin" in _role_names(db, user)
|
||||
|
||||
|
||||
def _is_partner(db, user) -> bool:
|
||||
return "Partner" in _role_names(db, user)
|
||||
|
||||
|
||||
def _is_staff(db, user) -> bool:
|
||||
return "Staff" in _role_names(db, user)
|
||||
|
||||
|
||||
def _can_bulk_manage_tasks(db, user) -> bool:
|
||||
# Operational bulk task management is intentionally limited to Firm Admin and Partner.
|
||||
# Firm Admin keeps the normal edit-permission gate.
|
||||
# Partner is allowed with service_tasks.view because partner visibility is enforced again
|
||||
# on every submitted task id in get_tasks_for_bulk_update(..., partner_user_id=user.id).
|
||||
if _is_firm_admin(db, user):
|
||||
return _has_perm(db, user, "service_tasks.edit")
|
||||
if _is_partner(db, user):
|
||||
return _has_perm(db, user, "service_tasks.view")
|
||||
return False
|
||||
|
||||
|
||||
def _can_assign_staff(db, user) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user)
|
||||
|
||||
|
||||
def _partner_visibility_user_id(db, user) -> int | None:
|
||||
return int(user.id) if _is_partner(db, user) else None
|
||||
|
||||
|
||||
def _staff_own_task_user_id(db, user) -> int | None:
|
||||
return int(user.id) if _is_staff(db, user) else None
|
||||
|
||||
|
||||
def _can_staff_update_own_task(db, user, task) -> bool:
|
||||
return _is_staff(db, user) and int(task.assigned_to_user_id or 0) == int(user.id) and _has_perm(db, user, "service_tasks.view")
|
||||
|
||||
|
||||
def _can_update_task_status(db, user, task) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user) or _can_staff_update_own_task(db, user, task)
|
||||
|
||||
|
||||
def _allowed_comment_type_codes(db, user) -> set[str]:
|
||||
if _can_bulk_manage_tasks(db, user):
|
||||
return {"internal_note", "client_clarification", "consultant_clarification", "partner_review_note"}
|
||||
if _is_staff(db, user):
|
||||
# Staff may communicate through consultants for clients where consultant is the communication channel.
|
||||
return {"internal_note", "client_clarification", "consultant_clarification"}
|
||||
return set()
|
||||
|
||||
|
||||
def _can_add_task_comment(db, user, task) -> bool:
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
return False
|
||||
return _can_update_task_status(db, user, task)
|
||||
|
||||
|
||||
def _comment_type_options_for_user(db, user):
|
||||
allowed = _allowed_comment_type_codes(db, user)
|
||||
return [(code, label) for code, label in TASK_COMMENT_TYPES if code in allowed]
|
||||
|
||||
|
||||
def _resolve_allowed_assignee_id(db, *, tenant_id: int, branch_id: int | None, assigned_to_user_id: int | None) -> int | None:
|
||||
if assigned_to_user_id is None:
|
||||
return None
|
||||
query = select(User.id).where(User.id == assigned_to_user_id, User.tenant_id == tenant_id, User.is_active.is_(True))
|
||||
if branch_id:
|
||||
query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None)))
|
||||
return assigned_to_user_id if db.execute(query).first() else None
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(
|
||||
request.session.get("active_tenant_id")
|
||||
or request.session.get("selected_tenant_id")
|
||||
or request.session.get("tenant_id")
|
||||
or user.tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "clients.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
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()
|
||||
return value or None
|
||||
|
||||
|
||||
def _assigned_user_filter(db, user) -> int | None:
|
||||
# Staff users see/update only their own assigned tasks. Partner visibility is handled separately.
|
||||
return _staff_own_task_user_id(db, user)
|
||||
|
||||
|
||||
def _can_manage_execution(db, user) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user)
|
||||
|
||||
|
||||
def _safe_int_list(values: list[str] | None) -> list[int]:
|
||||
ids: list[int] = []
|
||||
for value in values or []:
|
||||
try:
|
||||
ids.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
@router.get("")
|
||||
def execution_dashboard(request: Request, q: str = "", status: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
assigned_to_user_id = _assigned_user_filter(db, user)
|
||||
partner_user_id = _partner_visibility_user_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
stats = dashboard_stats(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
partner_user_id=partner_user_id,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
tasks = list_tasks_payload(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
partner_user_id=partner_user_id,
|
||||
status=status,
|
||||
q=q,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/work_tracker/dashboard.html",
|
||||
db,
|
||||
user,
|
||||
title="Work Tracker Dashboard",
|
||||
stats=stats,
|
||||
tasks=tasks,
|
||||
q=q,
|
||||
status=status,
|
||||
assignees=assignees,
|
||||
can_manage=_can_manage_execution(db, user),
|
||||
can_bulk_manage=_can_bulk_manage_tasks(db, user),
|
||||
can_assign_staff=_can_assign_staff(db, user),
|
||||
financial_year=financial_year,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/bulk-update")
|
||||
def task_bulk_update_submit(
|
||||
request: Request,
|
||||
task_ids: list[str] = Form(default=[]),
|
||||
bulk_status: str = Form(""),
|
||||
bulk_assigned_to_user_id: str = Form("__no_change__"),
|
||||
bulk_internal_target_date: str = Form(""),
|
||||
update_internal_target_date: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
if not _can_bulk_manage_tasks(db, user):
|
||||
return _redirect_denied()
|
||||
|
||||
ids = _safe_int_list(task_ids)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_user_id = _partner_visibility_user_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
tasks = get_tasks_for_bulk_update(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_ids=ids,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=None,
|
||||
partner_user_id=partner_user_id,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
update_assignee = bulk_assigned_to_user_id != "__no_change__"
|
||||
assigned_to_user_id = None
|
||||
if update_assignee and not _can_assign_staff(db, user):
|
||||
return _redirect_denied()
|
||||
if update_assignee and bulk_assigned_to_user_id.strip():
|
||||
try:
|
||||
requested_assignee_id = int(bulk_assigned_to_user_id)
|
||||
except ValueError:
|
||||
return _redirect_denied()
|
||||
assigned_to_user_id = _resolve_allowed_assignee_id(
|
||||
db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=requested_assignee_id
|
||||
)
|
||||
if assigned_to_user_id != requested_assignee_id:
|
||||
return _redirect_denied()
|
||||
target_date = parse_date_value(bulk_internal_target_date) if update_internal_target_date is not None else None
|
||||
apply_bulk_task_update(
|
||||
tasks,
|
||||
status=bulk_status.strip() or None,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
update_assignee=update_assignee,
|
||||
internal_target_date=target_date,
|
||||
update_internal_target_date=update_internal_target_date is not None,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
def subscription_execution_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
financial_year = _active_financial_year(request)
|
||||
rows = list_subscription_execution_payload(db, tenant_id=tenant_id, branch_id=branch_id, financial_year=financial_year, q=q)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/work_tracker/subscriptions.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Service Tasks",
|
||||
rows=rows,
|
||||
q=q,
|
||||
can_generate=_has_perm(db, user, "service_tasks.create"),
|
||||
financial_year=financial_year,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/subscriptions/{subscription_id}/generate")
|
||||
def generate_subscription_tasks(request: Request, subscription_id: int, csrf_token: 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)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.create")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
subscription = get_subscription_for_execution(db, tenant_id=tenant_id, subscription_id=subscription_id)
|
||||
if subscription and financial_year and subscription.financial_year != financial_year:
|
||||
return RedirectResponse(url="/services/work-tracker/subscriptions", status_code=303)
|
||||
if not subscription or not subscription.is_active or subscription.status != "active":
|
||||
return RedirectResponse(url="/services/work-tracker/subscriptions", status_code=303)
|
||||
|
||||
generate_tasks_for_subscription(db, subscription=subscription, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/edit")
|
||||
def task_edit_page(request: Request, task_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
|
||||
assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
comments = list_task_comments(db, tenant_id=tenant_id, task_id=task.id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/work_tracker/task_form.html",
|
||||
db,
|
||||
user,
|
||||
title="Update Service Task",
|
||||
task=task,
|
||||
assignees=assignees,
|
||||
comments=comments,
|
||||
comment_type_options=_comment_type_options_for_user(db, user),
|
||||
can_add_comment=_can_add_task_comment(db, user, task),
|
||||
can_edit=_can_update_task_status(db, user, task),
|
||||
can_manage_fields=_can_bulk_manage_tasks(db, user),
|
||||
can_reassign=_can_assign_staff(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/edit")
|
||||
def task_edit_submit(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
status: str = Form("pending"),
|
||||
priority: str = Form("normal"),
|
||||
assigned_to_user_id: str = Form(""),
|
||||
internal_target_date: str = Form(""),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: 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)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
if not _can_update_task_status(db, user, task):
|
||||
return _redirect_denied()
|
||||
|
||||
can_manage_fields = _can_bulk_manage_tasks(db, user)
|
||||
if can_manage_fields:
|
||||
if assigned_to_user_id.strip():
|
||||
try:
|
||||
requested_assignee_id = int(assigned_to_user_id)
|
||||
except ValueError:
|
||||
return _redirect_denied()
|
||||
resolved_assignee = _resolve_allowed_assignee_id(
|
||||
db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=requested_assignee_id
|
||||
)
|
||||
if resolved_assignee != requested_assignee_id:
|
||||
return _redirect_denied()
|
||||
else:
|
||||
resolved_assignee = None
|
||||
resolved_priority = priority
|
||||
resolved_internal_target_date = parse_date_value(internal_target_date)
|
||||
resolved_is_active = is_active is not None
|
||||
else:
|
||||
# Staff can update only status and work note for their own task.
|
||||
resolved_assignee = task.assigned_to_user_id
|
||||
resolved_priority = task.priority
|
||||
resolved_internal_target_date = task.internal_target_date
|
||||
resolved_is_active = task.is_active
|
||||
|
||||
apply_task_update(
|
||||
task,
|
||||
status=status,
|
||||
priority=resolved_priority,
|
||||
assigned_to_user_id=resolved_assignee,
|
||||
internal_target_date=resolved_internal_target_date,
|
||||
remarks=remarks,
|
||||
is_active=resolved_is_active,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
def task_comment_submit(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
comment_type: str = Form("internal_note"),
|
||||
visibility: str = Form("internal"),
|
||||
message: str = Form(""),
|
||||
csrf_token: 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)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
if not _can_add_task_comment(db, user, task):
|
||||
return _redirect_denied()
|
||||
if comment_type not in _allowed_comment_type_codes(db, user):
|
||||
return _redirect_denied()
|
||||
add_task_comment(
|
||||
db,
|
||||
task=task,
|
||||
comment_type=comment_type,
|
||||
visibility=visibility,
|
||||
message=message,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user