Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
+206
View File
@@ -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()
+337
View File
@@ -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
+532
View File
@@ -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()
+609
View File
@@ -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,
}
+259
View File
@@ -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()
+305
View File
@@ -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,
}
+500
View File
@@ -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])
+248
View File
@@ -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()
+243
View File
@@ -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
+532
View File
@@ -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()