Add periodic engagements and statutory due dates

This commit is contained in:
A R R R Associates
2026-08-05 12:31:39 +05:30
parent dd2a69dbaa
commit c35c89982e
9 changed files with 415 additions and 94 deletions
+18 -2
View File
@@ -23,7 +23,14 @@ from app.modules.services.models import (
ServiceDueDateRule,
)
from app.modules.services.services import normalize_code, normalize_engagement_type
from app.modules.services.client_services import assessment_year_from_financial_year, normalize_financial_year, review_partner_required_for_engagement, ensure_engagement_quality_workflow, enforce_quality_gate_on_subscription
from app.modules.services.client_services import (
assessment_year_from_financial_year,
normalize_financial_year,
normalize_period_label,
review_partner_required_for_engagement,
ensure_engagement_quality_workflow,
enforce_quality_gate_on_subscription,
)
from app.modules.services.due_dates import apply_due_date_rule_to_subscription, create_due_date_extension
TRUE_VALUES = {"1", "true", "yes", "y", "on"}
@@ -33,6 +40,7 @@ CLIENT_ASSIGNMENT_COLUMNS = [
"client_code",
"service_code",
"financial_year",
"period_label",
"engagement_type",
"assigned_partner_email",
"assigned_manager_email",
@@ -759,6 +767,11 @@ def import_client_service_assignments(
if not enabled:
raise ValueError("Service is not enabled for this firm.")
firm_selection, catalogue = enabled
period_label = normalize_period_label(
_clean(_cell(row, headers, "period_label")),
financial_year=financial_year,
recurrence_type=getattr(catalogue, "recurrence_type", None),
)
partner_email = _clean(_cell(row, headers, "assigned_partner_email"))
manager_email = _clean(_cell(row, headers, "assigned_manager_email"))
@@ -786,6 +799,7 @@ def import_client_service_assignments(
ClientServiceSubscription.client_id == client.id,
ClientServiceSubscription.service_catalogue_id == catalogue.id,
ClientServiceSubscription.financial_year == financial_year,
ClientServiceSubscription.period_label == period_label,
)
).scalar_one_or_none()
@@ -801,6 +815,7 @@ def import_client_service_assignments(
client_id=client.id,
service_catalogue_id=catalogue.id,
financial_year=financial_year,
period_label=period_label,
assessment_year=assessment_year_from_financial_year(financial_year),
created_by_user_id=current_user.id,
)
@@ -810,6 +825,7 @@ def import_client_service_assignments(
if getattr(sub, "is_locked", False):
raise ValueError("Existing engagement for this client/service/year is locked and cannot be updated.")
sub.financial_year = financial_year
sub.period_label = period_label
sub.assessment_year = assessment_year_from_financial_year(financial_year)
imported_engagement_type = _clean(_cell(row, headers, "engagement_type"))
sub.engagement_type = normalize_engagement_type(imported_engagement_type) if imported_engagement_type else (getattr(catalogue, "engagement_type", "non_assurance") or "non_assurance")
@@ -826,7 +842,7 @@ def import_client_service_assignments(
sub.is_active = is_active
sub.remarks = _clean(_cell(row, headers, "remarks")) or None
sub.updated_by_user_id = current_user.id
apply_due_date_rule_to_subscription(db, sub)
apply_due_date_rule_to_subscription(db, sub, force=True)
ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False)
enforce_quality_gate_on_subscription(sub)
except Exception as exc:
+44
View File
@@ -24,6 +24,48 @@ SUBSCRIPTION_STATUSES = [
ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff")
MONTHLY_PERIODS = (
(4, "Apr"), (5, "May"), (6, "Jun"), (7, "Jul"), (8, "Aug"), (9, "Sep"),
(10, "Oct"), (11, "Nov"), (12, "Dec"), (1, "Jan"), (2, "Feb"), (3, "Mar"),
)
QUARTERLY_PERIODS = ("Q1", "Q2", "Q3", "Q4")
def normalized_recurrence_type(value: str | None) -> str:
return (value or "").strip().lower().replace("-", "_").replace(" ", "_")
def recurrence_requires_period(value: str | None) -> bool:
return normalized_recurrence_type(value) in {"monthly", "quarterly"}
def period_choices_for_service(financial_year: str | None, recurrence_type: str | None) -> list[tuple[str, str]]:
recurrence = normalized_recurrence_type(recurrence_type)
fy = normalize_financial_year(financial_year)
start_year = int(fy.split("-", 1)[0])
end_year = start_year + 1
if recurrence == "monthly":
result = []
for month, label in MONTHLY_PERIODS:
year = start_year if month >= 4 else end_year
result.append((f"{year:04d}-{month:02d}", f"{label} {year}"))
return result
if recurrence == "quarterly":
return [(value, f"{value} {fy}") for value in QUARTERLY_PERIODS]
return [("", "Not applicable")]
def normalize_period_label(value: str | None, *, financial_year: str | None, recurrence_type: str | None) -> str:
recurrence = normalized_recurrence_type(recurrence_type)
raw = (value or "").strip()
if not recurrence_requires_period(recurrence):
return ""
valid = {code for code, _ in period_choices_for_service(financial_year, recurrence)}
if raw not in valid:
raise ValueError("A valid month or quarter is required for this recurring service.")
return raw
def current_financial_year(today: date | None = None) -> str:
today = today or date.today()
if today.month >= 4:
@@ -139,6 +181,7 @@ def get_existing_subscription(
client_id: int,
service_catalogue_id: int,
financial_year: str | None = None,
period_label: str | None = None,
) -> ClientServiceSubscription | None:
return db.execute(
select(ClientServiceSubscription).where(
@@ -146,6 +189,7 @@ def get_existing_subscription(
ClientServiceSubscription.client_id == client_id,
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
ClientServiceSubscription.financial_year == normalize_financial_year(financial_year),
ClientServiceSubscription.period_label == (period_label or "").strip(),
)
).scalar_one_or_none()
+157 -84
View File
@@ -27,6 +27,9 @@ from app.modules.services.client_services import (
get_existing_subscription,
get_subscription,
normalize_financial_year,
normalize_period_label,
period_choices_for_service,
recurrence_requires_period,
list_assignable_users,
list_clients_for_assignment,
list_enabled_services_for_assignment,
@@ -241,6 +244,7 @@ def subscription_create_page(request: Request, client_id: int | None = None):
review_partners=review_partners,
selected_client_id=client_id,
financial_year=_active_financial_year(request),
period_choices=[],
)
finally:
db.close()
@@ -257,6 +261,8 @@ def subscription_create_submit(
performing_partner_user_id: str = Form(""),
review_partner_user_id: str = Form(""),
financial_year: str = Form(""),
period_label: str = Form(""),
generate_all_periods: str | None = Form(None),
start_date: str = Form(""),
end_date: str = Form(""),
expiry_date: str = Form(""),
@@ -285,62 +291,89 @@ def subscription_create_submit(
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,
)
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
if recurrence_requires_period(recurrence_type):
available_periods = period_choices_for_service(selected_financial_year, recurrence_type)
if generate_all_periods is not None:
requested_periods = [code for code, _label in available_periods]
else:
try:
requested_periods = [normalize_period_label(period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type)]
except ValueError:
return RedirectResponse(url="/services/engagements/new?error=period", status_code=303)
else:
requested_periods = [""]
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:
created_rows = []
existing_rows = []
for requested_period in requested_periods:
existing = get_existing_subscription(
db,
tenant_id=tenant_id,
client_id=client_id,
service_catalogue_id=service_catalogue_id,
financial_year=selected_financial_year,
period_label=requested_period,
)
if existing:
existing_rows.append(existing)
continue
row = ClientServiceSubscription(
tenant_id=tenant_id,
client_id=client_id,
service_catalogue_id=service_catalogue_id,
financial_year=selected_financial_year,
period_label=requested_period,
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)
created_rows.append(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
requested_performing_partner_id = int(performing_partner_user_id) if performing_partner_user_id.strip() else None
row.performing_partner_user_id = requested_performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or row.assigned_partner_user_id
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)
if not created_rows:
row = existing_rows[0]
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)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
row = created_rows[0]
rows_to_update = created_rows or [row]
for row in rows_to_update:
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
requested_performing_partner_id = int(performing_partner_user_id) if performing_partner_user_id.strip() else None
row.performing_partner_user_id = requested_performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or row.assigned_partner_user_id
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, force=True)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
db.commit()
db.refresh(row)
if len(created_rows) > 1:
return RedirectResponse(url=f"/services/engagements?financial_year={selected_financial_year}&bulk_created={len(created_rows)}", status_code=303)
return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303)
finally:
db.close()
@@ -398,6 +431,7 @@ def subscription_bulk_create_page(request: Request, error: str = ""):
"review_partner": "The selected Review Partner is not available for this firm.",
"review_partner_required": "Review Partner is mandatory for an assurance engagement.",
"clients": "Select at least one permitted client.",
"period": "Select a valid month or quarter for the recurring service.",
}.get(error),
)
finally:
@@ -415,6 +449,8 @@ def subscription_bulk_create_submit(
performing_partner_user_id: str = Form(""),
review_partner_user_id: str = Form(""),
financial_year: str = Form(""),
period_label: str = Form(""),
generate_all_periods: str | None = Form(None),
remarks: str = Form(""),
csrf_token: str = Form(...),
):
@@ -451,6 +487,19 @@ def subscription_bulk_create_submit(
if not firm_selection:
return RedirectResponse(url="/services/engagements/bulk-new?error=service", status_code=303)
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
if recurrence_requires_period(recurrence_type):
available_periods = period_choices_for_service(selected_financial_year, recurrence_type)
if generate_all_periods is not None:
requested_periods = [code for code, _label in available_periods]
else:
try:
requested_periods = [normalize_period_label(period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type)]
except ValueError:
return RedirectResponse(url="/services/engagements/bulk-new?error=period", status_code=303)
else:
requested_periods = [""]
partners = list_assignable_users(db, tenant_id=tenant_id, role_names=("Partner",))
managers = list_assignable_users(db, tenant_id=tenant_id, role_names=("Branch Manager",))
staff_users = list_assignable_users(db, tenant_id=tenant_id, role_names=("Staff",))
@@ -521,56 +570,59 @@ def subscription_bulk_create_submit(
)
for client_id in selected_client_ids:
existing = get_existing_subscription(
db,
tenant_id=tenant_id,
client_id=client_id,
service_catalogue_id=service_catalogue_id,
financial_year=selected_financial_year,
)
if existing:
existing_count += 1
continue
client = db.get(Client, client_id)
if not client or client.tenant_id != tenant_id:
skipped_count += 1
skipped_count += len(requested_periods)
continue
row = ClientServiceSubscription(
tenant_id=tenant_id,
branch_id=engagement_branch_id,
client_id=client_id,
service_catalogue_id=service_catalogue_id,
firm_service_selection_id=firm_selection.id,
assigned_partner_user_id=assigned_partner_user_id,
performing_partner_user_id=performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or assigned_partner_user_id,
assigned_manager_user_id=manager_id,
assigned_staff_user_id=staff_id,
review_partner_user_id=(
review_partner_id
if review_partner_id is not None
else (
getattr(client, "default_review_partner_user_id", None)
if requires_review_partner
else None
)
),
financial_year=selected_financial_year,
assessment_year=assessment_year_from_financial_year(selected_financial_year),
engagement_type=engagement_type,
status="active",
remarks=remarks.strip() or None,
is_active=True,
created_by_user_id=user.id,
updated_by_user_id=user.id,
)
db.add(row)
db.flush()
apply_due_date_rule_to_subscription(db, row)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
created_count += 1
for requested_period in requested_periods:
existing = get_existing_subscription(
db,
tenant_id=tenant_id,
client_id=client_id,
service_catalogue_id=service_catalogue_id,
financial_year=selected_financial_year,
period_label=requested_period,
)
if existing:
existing_count += 1
continue
row = ClientServiceSubscription(
tenant_id=tenant_id,
branch_id=engagement_branch_id,
client_id=client_id,
service_catalogue_id=service_catalogue_id,
firm_service_selection_id=firm_selection.id,
assigned_partner_user_id=assigned_partner_user_id,
performing_partner_user_id=performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or assigned_partner_user_id,
assigned_manager_user_id=manager_id,
assigned_staff_user_id=staff_id,
review_partner_user_id=(
review_partner_id
if review_partner_id is not None
else (
getattr(client, "default_review_partner_user_id", None)
if requires_review_partner
else None
)
),
financial_year=selected_financial_year,
period_label=requested_period,
assessment_year=assessment_year_from_financial_year(selected_financial_year),
engagement_type=engagement_type,
status="active",
remarks=remarks.strip() or None,
is_active=True,
created_by_user_id=user.id,
updated_by_user_id=user.id,
)
db.add(row)
db.flush()
apply_due_date_rule_to_subscription(db, row, force=True)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
created_count += 1
db.commit()
return RedirectResponse(
@@ -1011,6 +1063,7 @@ def subscription_edit_page(request: Request, subscription_id: int):
review_partners=review_partners,
selected_client_id=row.client_id,
financial_year=row.financial_year,
period_choices=period_choices_for_service(row.financial_year, getattr(row.catalogue, "recurrence_type", None)),
)
finally:
db.close()
@@ -1025,6 +1078,7 @@ def subscription_edit_submit(
assigned_staff_user_id: str = Form(""),
performing_partner_user_id: str = Form(""),
review_partner_user_id: str = Form(""),
period_label: str = Form(""),
start_date: str = Form(""),
end_date: str = Form(""),
expiry_date: str = Form(""),
@@ -1054,6 +1108,25 @@ def subscription_edit_submit(
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)
try:
new_period_label = normalize_period_label(
period_label,
financial_year=row.financial_year,
recurrence_type=getattr(row.catalogue, "recurrence_type", None),
)
except ValueError:
return RedirectResponse(url=f"/services/engagements/{row.id}/edit?error=period", status_code=303)
duplicate = get_existing_subscription(
db,
tenant_id=tenant_id,
client_id=row.client_id,
service_catalogue_id=row.service_catalogue_id,
financial_year=row.financial_year,
period_label=new_period_label,
)
if duplicate and duplicate.id != row.id:
return RedirectResponse(url=f"/services/engagements/{row.id}/edit?error=duplicate_period", status_code=303)
row.period_label = new_period_label
row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None
requested_performing_partner_id = int(performing_partner_user_id) if performing_partner_user_id.strip() else None
row.performing_partner_user_id = requested_performing_partner_id or getattr(row.client, "default_performing_partner_user_id", None) or row.assigned_partner_user_id
@@ -1070,7 +1143,7 @@ def subscription_edit_submit(
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)
apply_due_date_rule_to_subscription(db, row, force=True)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
db.commit()
+4 -1
View File
@@ -381,7 +381,8 @@ class ClientServiceSubscription(CommonBase):
"client_id",
"service_catalogue_id",
"financial_year",
name="uq_client_service_subscription_tenant_client_service_year",
"period_label",
name="uq_client_service_subscription_tenant_client_service_year_period",
),
)
@@ -400,6 +401,7 @@ class ClientServiceSubscription(CommonBase):
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)
period_label: Mapped[str] = mapped_column(String(30), nullable=False, default="", server_default="", 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)
@@ -654,6 +656,7 @@ class ClientServiceTaskInstance(CommonBase):
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)
period_label: Mapped[str] = mapped_column(String(30), nullable=False, default="", server_default="", 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)
@@ -19,7 +19,7 @@
<div class="grid gap-4 rounded-2xl bg-white p-5 shadow-soft md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
<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">
<input type="text" id="bulk-financial-year" 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">
</div>
<div class="md:col-span-1 xl:col-span-2">
@@ -35,6 +35,18 @@
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
<select name="period_label" id="bulk-period" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">Not applicable</option>
</select>
<label id="bulk-generate-all-wrap" class="mt-2 hidden items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="generate_all_periods" id="bulk-generate-all" value="1" class="rounded border-slate-300">
Generate all periods for the financial year
</label>
<p class="mt-1 text-xs text-slate-500">For monthly or quarterly services, due dates are calculated separately for each generated period.</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
<select name="assigned_partner_user_id" id="bulk-partner" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
@@ -131,7 +143,7 @@
</div>
<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">Existing engagements for the same client, service and financial year are skipped automatically. No existing engagement is overwritten.</p>
<p class="text-sm text-slate-600">Existing engagements for the same client, service, financial year and period are skipped automatically. No existing engagement is overwritten.</p>
<div class="flex 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 type="submit" id="create-engagements-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Create Engagements</button>
@@ -144,6 +156,10 @@
(function () {
const form = document.getElementById('bulk-engagement-form');
const service = document.getElementById('bulk-service');
const fy = document.getElementById('bulk-financial-year');
const period = document.getElementById('bulk-period');
const generateAll = document.getElementById('bulk-generate-all');
const generateWrap = document.getElementById('bulk-generate-all-wrap');
const info = document.getElementById('service-derived-info');
const reviewPartner = document.getElementById('bulk-review-partner');
const reviewRequirement = document.getElementById('review-partner-requirement');
@@ -169,6 +185,21 @@
reviewHelp.textContent = assurance
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
: 'Optional for non-assurance engagements. A selected Review Partner is saved for every engagement created in this batch.';
const fyMatch = (fy.value || '').trim().match(/^(\d{4})-(\d{2}|\d{4})$/);
const startYear = fyMatch ? Number(fyMatch[1]) : null;
period.innerHTML = '';
generateAll.checked = false;
period.disabled = false;
function addPeriod(value, label) { const o = document.createElement('option'); o.value = value; o.textContent = label; period.appendChild(o); }
if (recurrence === 'monthly' && startYear) {
[['04','Apr',startYear],['05','May',startYear],['06','Jun',startYear],['07','Jul',startYear],['08','Aug',startYear],['09','Sep',startYear],['10','Oct',startYear],['11','Nov',startYear],['12','Dec',startYear],['01','Jan',startYear+1],['02','Feb',startYear+1],['03','Mar',startYear+1]].forEach(row => addPeriod(`${row[2]}-${row[0]}`, `${row[1]} ${row[2]}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else if (recurrence === 'quarterly' && startYear) {
['Q1','Q2','Q3','Q4'].forEach(q => addPeriod(q, `${q} ${fy.value}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else {
addPeriod('', 'Not applicable'); period.required = false; generateWrap.classList.add('hidden'); generateWrap.classList.remove('flex');
}
info.textContent = option && option.value
? `Type: ${assurance ? 'Assurance' : 'Non-Assurance'}${recurrence ? ' · Recurrence: ' + recurrence.replaceAll('_', ' ') : ''} · Due-date rule and workflow are taken from the service setup.`
: 'Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.';
@@ -199,6 +230,8 @@
}
service.addEventListener('change', updateServiceInfo);
fy.addEventListener('input', updateServiceInfo);
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
search.addEventListener('input', updateVisibleRows);
clear.addEventListener('click', () => { search.value = ''; updateVisibleRows(); search.focus(); });
selectAll.addEventListener('change', () => { visibleBoxes().forEach(box => { box.checked = selectAll.checked; }); updateCount(); });
@@ -10,7 +10,7 @@
</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">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">Return / Engagement Period</dt><dd>{{ row.period_label or 'Not applicable' }}</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">Engagement Dates</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">Performing Partner</dt><dd>{{ row.performing_partner.full_name if row.performing_partner and row.performing_partner.full_name else (row.performing_partner.email if row.performing_partner else (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>
@@ -17,7 +17,7 @@
{% 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">
<input type="text" id="engagement-financial-year" 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>
@@ -40,15 +40,33 @@
{{ 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">
<select name="service_catalogue_id" id="engagement-service" 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>
<option value="{{ selection.catalogue.id }}" data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }}) - {{ 'Assurance' if selection.catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div id="engagement-period-field">
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
{% if mode == 'edit' %}
<select name="period_label" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for value, label in period_choices %}<option value="{{ value }}" {% if subscription and subscription.period_label == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
{% else %}
<select name="period_label" id="engagement-period" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">Not applicable</option>
</select>
<label id="generate-all-periods-wrap" class="mt-2 hidden items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="generate_all_periods" id="generate-all-periods" value="1" class="rounded border-slate-300">
Generate all periods for this financial year
</label>
{% endif %}
<p class="mt-1 text-xs text-slate-500">Monthly and quarterly services require a period so the statutory due date can be calculated.</p>
</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">Performing Partner</label><select name="performing_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Use client default / Engagement Partner</option>{% for u in performing_partners or [] %}<option value="{{ u.id }}" {% if subscription and subscription.performing_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">The Partner who directly performs or supervises this engagement.</p></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>
@@ -64,4 +82,43 @@
<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>
{% if mode != 'edit' %}
<script>
(function () {
const service = document.getElementById('engagement-service');
const fy = document.getElementById('engagement-financial-year');
const period = document.getElementById('engagement-period');
const generateWrap = document.getElementById('generate-all-periods-wrap');
const generateAll = document.getElementById('generate-all-periods');
if (!service || !fy || !period) return;
function fyStart() {
const match = (fy.value || '').trim().match(/^(\d{4})-(\d{2}|\d{4})$/);
return match ? Number(match[1]) : null;
}
function add(value, label) {
const option = document.createElement('option'); option.value = value; option.textContent = label; period.appendChild(option);
}
function rebuild() {
const selected = service.options[service.selectedIndex];
const recurrence = ((selected && selected.dataset.recurrence) || '').toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
const start = fyStart();
period.innerHTML = '';
generateAll.checked = false;
if (recurrence === 'monthly' && start) {
[['04','Apr',start],['05','May',start],['06','Jun',start],['07','Jul',start],['08','Aug',start],['09','Sep',start],['10','Oct',start],['11','Nov',start],['12','Dec',start],['01','Jan',start+1],['02','Feb',start+1],['03','Mar',start+1]].forEach(row => add(`${row[2]}-${row[0]}`, `${row[1]} ${row[2]}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else if (recurrence === 'quarterly' && start) {
['Q1','Q2','Q3','Q4'].forEach(q => add(q, `${q} ${fy.value}`));
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
} else {
add('', 'Not applicable'); period.required = false; generateWrap.classList.add('hidden'); generateWrap.classList.remove('flex');
}
}
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
service.addEventListener('change', rebuild); fy.addEventListener('input', rebuild); rebuild();
})();
</script>
{% endif %}
{% endblock %}
@@ -84,7 +84,7 @@
{% 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>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div><div>Period: {{ row.period_label 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>Performing: {{ row.performing_partner.full_name if row.performing_partner and row.performing_partner.full_name else (row.performing_partner.email if row.performing_partner else (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>