Add periodic engagements and statutory due dates
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
"""add recurring engagement period support
|
||||||
|
|
||||||
|
Revision ID: 20260805_periodic_engagements
|
||||||
|
Revises: 20260805_performing_partner
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
revision = "20260805_periodic_engagements"
|
||||||
|
down_revision = "20260805_performing_partner"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLE = "client_service_subscriptions"
|
||||||
|
OLD_UQ = "uq_client_service_subscription_tenant_client_service_year"
|
||||||
|
NEW_UQ = "uq_client_service_subscription_tenant_client_service_year_period"
|
||||||
|
INDEX = "ix_client_service_subscriptions_period_label"
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector():
|
||||||
|
return inspect(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def _columns() -> set[str]:
|
||||||
|
return {row["name"] for row in _inspector().get_columns(TABLE)}
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_constraints() -> set[str]:
|
||||||
|
return {row.get("name") for row in _inspector().get_unique_constraints(TABLE) if row.get("name")}
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes() -> set[str]:
|
||||||
|
return {row.get("name") for row in _inspector().get_indexes(TABLE) if row.get("name")}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if "period_label" not in _columns():
|
||||||
|
op.add_column(TABLE, sa.Column("period_label", sa.String(length=30), nullable=False, server_default=""))
|
||||||
|
|
||||||
|
op.execute("UPDATE client_service_subscriptions SET period_label = '' WHERE period_label IS NULL")
|
||||||
|
|
||||||
|
uniques = _unique_constraints()
|
||||||
|
dialect = op.get_bind().dialect.name
|
||||||
|
if dialect == "sqlite":
|
||||||
|
with op.batch_alter_table(TABLE, recreate="always") as batch:
|
||||||
|
if OLD_UQ in uniques:
|
||||||
|
batch.drop_constraint(OLD_UQ, type_="unique")
|
||||||
|
if NEW_UQ not in uniques:
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
NEW_UQ,
|
||||||
|
["tenant_id", "client_id", "service_catalogue_id", "financial_year", "period_label"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if OLD_UQ in uniques:
|
||||||
|
op.drop_constraint(OLD_UQ, TABLE, type_="unique")
|
||||||
|
if NEW_UQ not in _unique_constraints():
|
||||||
|
op.create_unique_constraint(
|
||||||
|
NEW_UQ,
|
||||||
|
TABLE,
|
||||||
|
["tenant_id", "client_id", "service_catalogue_id", "financial_year", "period_label"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if INDEX not in _indexes():
|
||||||
|
op.create_index(INDEX, TABLE, ["period_label"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
if INDEX in _indexes():
|
||||||
|
op.drop_index(INDEX, table_name=TABLE)
|
||||||
|
|
||||||
|
uniques = _unique_constraints()
|
||||||
|
dialect = op.get_bind().dialect.name
|
||||||
|
if dialect == "sqlite":
|
||||||
|
with op.batch_alter_table(TABLE, recreate="always") as batch:
|
||||||
|
if NEW_UQ in uniques:
|
||||||
|
batch.drop_constraint(NEW_UQ, type_="unique")
|
||||||
|
if OLD_UQ not in uniques:
|
||||||
|
batch.create_unique_constraint(
|
||||||
|
OLD_UQ,
|
||||||
|
["tenant_id", "client_id", "service_catalogue_id", "financial_year"],
|
||||||
|
)
|
||||||
|
if "period_label" in _columns():
|
||||||
|
batch.drop_column("period_label")
|
||||||
|
else:
|
||||||
|
if NEW_UQ in uniques:
|
||||||
|
op.drop_constraint(NEW_UQ, TABLE, type_="unique")
|
||||||
|
if OLD_UQ not in _unique_constraints():
|
||||||
|
op.create_unique_constraint(
|
||||||
|
OLD_UQ,
|
||||||
|
TABLE,
|
||||||
|
["tenant_id", "client_id", "service_catalogue_id", "financial_year"],
|
||||||
|
)
|
||||||
|
if "period_label" in _columns():
|
||||||
|
op.drop_column(TABLE, "period_label")
|
||||||
@@ -23,7 +23,14 @@ from app.modules.services.models import (
|
|||||||
ServiceDueDateRule,
|
ServiceDueDateRule,
|
||||||
)
|
)
|
||||||
from app.modules.services.services import normalize_code, normalize_engagement_type
|
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
|
from app.modules.services.due_dates import apply_due_date_rule_to_subscription, create_due_date_extension
|
||||||
|
|
||||||
TRUE_VALUES = {"1", "true", "yes", "y", "on"}
|
TRUE_VALUES = {"1", "true", "yes", "y", "on"}
|
||||||
@@ -33,6 +40,7 @@ CLIENT_ASSIGNMENT_COLUMNS = [
|
|||||||
"client_code",
|
"client_code",
|
||||||
"service_code",
|
"service_code",
|
||||||
"financial_year",
|
"financial_year",
|
||||||
|
"period_label",
|
||||||
"engagement_type",
|
"engagement_type",
|
||||||
"assigned_partner_email",
|
"assigned_partner_email",
|
||||||
"assigned_manager_email",
|
"assigned_manager_email",
|
||||||
@@ -759,6 +767,11 @@ def import_client_service_assignments(
|
|||||||
if not enabled:
|
if not enabled:
|
||||||
raise ValueError("Service is not enabled for this firm.")
|
raise ValueError("Service is not enabled for this firm.")
|
||||||
firm_selection, catalogue = enabled
|
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"))
|
partner_email = _clean(_cell(row, headers, "assigned_partner_email"))
|
||||||
manager_email = _clean(_cell(row, headers, "assigned_manager_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.client_id == client.id,
|
||||||
ClientServiceSubscription.service_catalogue_id == catalogue.id,
|
ClientServiceSubscription.service_catalogue_id == catalogue.id,
|
||||||
ClientServiceSubscription.financial_year == financial_year,
|
ClientServiceSubscription.financial_year == financial_year,
|
||||||
|
ClientServiceSubscription.period_label == period_label,
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
@@ -801,6 +815,7 @@ def import_client_service_assignments(
|
|||||||
client_id=client.id,
|
client_id=client.id,
|
||||||
service_catalogue_id=catalogue.id,
|
service_catalogue_id=catalogue.id,
|
||||||
financial_year=financial_year,
|
financial_year=financial_year,
|
||||||
|
period_label=period_label,
|
||||||
assessment_year=assessment_year_from_financial_year(financial_year),
|
assessment_year=assessment_year_from_financial_year(financial_year),
|
||||||
created_by_user_id=current_user.id,
|
created_by_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
@@ -810,6 +825,7 @@ def import_client_service_assignments(
|
|||||||
if getattr(sub, "is_locked", False):
|
if getattr(sub, "is_locked", False):
|
||||||
raise ValueError("Existing engagement for this client/service/year is locked and cannot be updated.")
|
raise ValueError("Existing engagement for this client/service/year is locked and cannot be updated.")
|
||||||
sub.financial_year = financial_year
|
sub.financial_year = financial_year
|
||||||
|
sub.period_label = period_label
|
||||||
sub.assessment_year = assessment_year_from_financial_year(financial_year)
|
sub.assessment_year = assessment_year_from_financial_year(financial_year)
|
||||||
imported_engagement_type = _clean(_cell(row, headers, "engagement_type"))
|
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")
|
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.is_active = is_active
|
||||||
sub.remarks = _clean(_cell(row, headers, "remarks")) or None
|
sub.remarks = _clean(_cell(row, headers, "remarks")) or None
|
||||||
sub.updated_by_user_id = current_user.id
|
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)
|
ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False)
|
||||||
enforce_quality_gate_on_subscription(sub)
|
enforce_quality_gate_on_subscription(sub)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -24,6 +24,48 @@ SUBSCRIPTION_STATUSES = [
|
|||||||
ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff")
|
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:
|
def current_financial_year(today: date | None = None) -> str:
|
||||||
today = today or date.today()
|
today = today or date.today()
|
||||||
if today.month >= 4:
|
if today.month >= 4:
|
||||||
@@ -139,6 +181,7 @@ def get_existing_subscription(
|
|||||||
client_id: int,
|
client_id: int,
|
||||||
service_catalogue_id: int,
|
service_catalogue_id: int,
|
||||||
financial_year: str | None = None,
|
financial_year: str | None = None,
|
||||||
|
period_label: str | None = None,
|
||||||
) -> ClientServiceSubscription | None:
|
) -> ClientServiceSubscription | None:
|
||||||
return db.execute(
|
return db.execute(
|
||||||
select(ClientServiceSubscription).where(
|
select(ClientServiceSubscription).where(
|
||||||
@@ -146,6 +189,7 @@ def get_existing_subscription(
|
|||||||
ClientServiceSubscription.client_id == client_id,
|
ClientServiceSubscription.client_id == client_id,
|
||||||
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
|
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
|
||||||
ClientServiceSubscription.financial_year == normalize_financial_year(financial_year),
|
ClientServiceSubscription.financial_year == normalize_financial_year(financial_year),
|
||||||
|
ClientServiceSubscription.period_label == (period_label or "").strip(),
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ from app.modules.services.client_services import (
|
|||||||
get_existing_subscription,
|
get_existing_subscription,
|
||||||
get_subscription,
|
get_subscription,
|
||||||
normalize_financial_year,
|
normalize_financial_year,
|
||||||
|
normalize_period_label,
|
||||||
|
period_choices_for_service,
|
||||||
|
recurrence_requires_period,
|
||||||
list_assignable_users,
|
list_assignable_users,
|
||||||
list_clients_for_assignment,
|
list_clients_for_assignment,
|
||||||
list_enabled_services_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,
|
review_partners=review_partners,
|
||||||
selected_client_id=client_id,
|
selected_client_id=client_id,
|
||||||
financial_year=_active_financial_year(request),
|
financial_year=_active_financial_year(request),
|
||||||
|
period_choices=[],
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -257,6 +261,8 @@ def subscription_create_submit(
|
|||||||
performing_partner_user_id: str = Form(""),
|
performing_partner_user_id: str = Form(""),
|
||||||
review_partner_user_id: str = Form(""),
|
review_partner_user_id: str = Form(""),
|
||||||
financial_year: str = Form(""),
|
financial_year: str = Form(""),
|
||||||
|
period_label: str = Form(""),
|
||||||
|
generate_all_periods: str | None = Form(None),
|
||||||
start_date: str = Form(""),
|
start_date: str = Form(""),
|
||||||
end_date: str = Form(""),
|
end_date: str = Form(""),
|
||||||
expiry_date: str = Form(""),
|
expiry_date: str = Form(""),
|
||||||
@@ -285,62 +291,89 @@ def subscription_create_submit(
|
|||||||
if not firm_selection:
|
if not firm_selection:
|
||||||
return RedirectResponse(url="/services/engagements/new", status_code=303)
|
return RedirectResponse(url="/services/engagements/new", status_code=303)
|
||||||
|
|
||||||
existing = get_existing_subscription(
|
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
|
||||||
db,
|
if recurrence_requires_period(recurrence_type):
|
||||||
tenant_id=tenant_id,
|
available_periods = period_choices_for_service(selected_financial_year, recurrence_type)
|
||||||
client_id=client_id,
|
if generate_all_periods is not None:
|
||||||
service_catalogue_id=service_catalogue_id,
|
requested_periods = [code for code, _label in available_periods]
|
||||||
financial_year=selected_financial_year,
|
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)
|
client = db.get(Client, client_id)
|
||||||
if not client or client.tenant_id != tenant_id:
|
if not client or client.tenant_id != tenant_id:
|
||||||
return RedirectResponse(url="/services/engagements/new", status_code=303)
|
return RedirectResponse(url="/services/engagements/new", status_code=303)
|
||||||
|
|
||||||
if existing:
|
created_rows = []
|
||||||
row = existing
|
existing_rows = []
|
||||||
else:
|
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(
|
row = ClientServiceSubscription(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
service_catalogue_id=service_catalogue_id,
|
service_catalogue_id=service_catalogue_id,
|
||||||
financial_year=selected_financial_year,
|
financial_year=selected_financial_year,
|
||||||
|
period_label=requested_period,
|
||||||
assessment_year=assessment_year_from_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",
|
engagement_type=getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance",
|
||||||
created_by_user_id=user.id,
|
created_by_user_id=user.id,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
created_rows.append(row)
|
||||||
|
|
||||||
if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row):
|
if not created_rows:
|
||||||
return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303)
|
row = existing_rows[0]
|
||||||
|
|
||||||
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:
|
else:
|
||||||
row.review_partner_user_id = None
|
row = created_rows[0]
|
||||||
row.start_date = parse_date(start_date)
|
rows_to_update = created_rows or [row]
|
||||||
row.end_date = parse_date(end_date)
|
for row in rows_to_update:
|
||||||
row.expiry_date = parse_date(expiry_date)
|
if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row):
|
||||||
row.status = status or "active"
|
return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303)
|
||||||
row.remarks = remarks.strip() or None
|
|
||||||
row.is_active = is_active is not None
|
row.financial_year = selected_financial_year
|
||||||
row.updated_by_user_id = user.id
|
row.assessment_year = assessment_year_from_financial_year(selected_financial_year)
|
||||||
apply_due_date_rule_to_subscription(db, row)
|
if not getattr(row, "engagement_type", None):
|
||||||
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
|
row.engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance"
|
||||||
enforce_quality_gate_on_subscription(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
|
||||||
|
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.commit()
|
||||||
db.refresh(row)
|
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)
|
return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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": "The selected Review Partner is not available for this firm.",
|
||||||
"review_partner_required": "Review Partner is mandatory for an assurance engagement.",
|
"review_partner_required": "Review Partner is mandatory for an assurance engagement.",
|
||||||
"clients": "Select at least one permitted client.",
|
"clients": "Select at least one permitted client.",
|
||||||
|
"period": "Select a valid month or quarter for the recurring service.",
|
||||||
}.get(error),
|
}.get(error),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -415,6 +449,8 @@ def subscription_bulk_create_submit(
|
|||||||
performing_partner_user_id: str = Form(""),
|
performing_partner_user_id: str = Form(""),
|
||||||
review_partner_user_id: str = Form(""),
|
review_partner_user_id: str = Form(""),
|
||||||
financial_year: str = Form(""),
|
financial_year: str = Form(""),
|
||||||
|
period_label: str = Form(""),
|
||||||
|
generate_all_periods: str | None = Form(None),
|
||||||
remarks: str = Form(""),
|
remarks: str = Form(""),
|
||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
):
|
):
|
||||||
@@ -451,6 +487,19 @@ def subscription_bulk_create_submit(
|
|||||||
if not firm_selection:
|
if not firm_selection:
|
||||||
return RedirectResponse(url="/services/engagements/bulk-new?error=service", status_code=303)
|
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",))
|
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",))
|
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",))
|
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:
|
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)
|
client = db.get(Client, client_id)
|
||||||
if not client or client.tenant_id != tenant_id:
|
if not client or client.tenant_id != tenant_id:
|
||||||
skipped_count += 1
|
skipped_count += len(requested_periods)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
row = ClientServiceSubscription(
|
for requested_period in requested_periods:
|
||||||
tenant_id=tenant_id,
|
existing = get_existing_subscription(
|
||||||
branch_id=engagement_branch_id,
|
db,
|
||||||
client_id=client_id,
|
tenant_id=tenant_id,
|
||||||
service_catalogue_id=service_catalogue_id,
|
client_id=client_id,
|
||||||
firm_service_selection_id=firm_selection.id,
|
service_catalogue_id=service_catalogue_id,
|
||||||
assigned_partner_user_id=assigned_partner_user_id,
|
financial_year=selected_financial_year,
|
||||||
performing_partner_user_id=performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or assigned_partner_user_id,
|
period_label=requested_period,
|
||||||
assigned_manager_user_id=manager_id,
|
)
|
||||||
assigned_staff_user_id=staff_id,
|
if existing:
|
||||||
review_partner_user_id=(
|
existing_count += 1
|
||||||
review_partner_id
|
continue
|
||||||
if review_partner_id is not None
|
|
||||||
else (
|
row = ClientServiceSubscription(
|
||||||
getattr(client, "default_review_partner_user_id", None)
|
tenant_id=tenant_id,
|
||||||
if requires_review_partner
|
branch_id=engagement_branch_id,
|
||||||
else None
|
client_id=client_id,
|
||||||
)
|
service_catalogue_id=service_catalogue_id,
|
||||||
),
|
firm_service_selection_id=firm_selection.id,
|
||||||
financial_year=selected_financial_year,
|
assigned_partner_user_id=assigned_partner_user_id,
|
||||||
assessment_year=assessment_year_from_financial_year(selected_financial_year),
|
performing_partner_user_id=performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or assigned_partner_user_id,
|
||||||
engagement_type=engagement_type,
|
assigned_manager_user_id=manager_id,
|
||||||
status="active",
|
assigned_staff_user_id=staff_id,
|
||||||
remarks=remarks.strip() or None,
|
review_partner_user_id=(
|
||||||
is_active=True,
|
review_partner_id
|
||||||
created_by_user_id=user.id,
|
if review_partner_id is not None
|
||||||
updated_by_user_id=user.id,
|
else (
|
||||||
)
|
getattr(client, "default_review_partner_user_id", None)
|
||||||
db.add(row)
|
if requires_review_partner
|
||||||
db.flush()
|
else None
|
||||||
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)
|
financial_year=selected_financial_year,
|
||||||
created_count += 1
|
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()
|
db.commit()
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
@@ -1011,6 +1063,7 @@ def subscription_edit_page(request: Request, subscription_id: int):
|
|||||||
review_partners=review_partners,
|
review_partners=review_partners,
|
||||||
selected_client_id=row.client_id,
|
selected_client_id=row.client_id,
|
||||||
financial_year=row.financial_year,
|
financial_year=row.financial_year,
|
||||||
|
period_choices=period_choices_for_service(row.financial_year, getattr(row.catalogue, "recurrence_type", None)),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1025,6 +1078,7 @@ def subscription_edit_submit(
|
|||||||
assigned_staff_user_id: str = Form(""),
|
assigned_staff_user_id: str = Form(""),
|
||||||
performing_partner_user_id: str = Form(""),
|
performing_partner_user_id: str = Form(""),
|
||||||
review_partner_user_id: str = Form(""),
|
review_partner_user_id: str = Form(""),
|
||||||
|
period_label: str = Form(""),
|
||||||
start_date: str = Form(""),
|
start_date: str = Form(""),
|
||||||
end_date: str = Form(""),
|
end_date: str = Form(""),
|
||||||
expiry_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):
|
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)
|
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
|
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
|
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
|
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.remarks = remarks.strip() or None
|
||||||
row.is_active = is_active is not None
|
row.is_active = is_active is not None
|
||||||
row.updated_by_user_id = user.id
|
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)
|
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
|
||||||
enforce_quality_gate_on_subscription(row)
|
enforce_quality_gate_on_subscription(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -381,7 +381,8 @@ class ClientServiceSubscription(CommonBase):
|
|||||||
"client_id",
|
"client_id",
|
||||||
"service_catalogue_id",
|
"service_catalogue_id",
|
||||||
"financial_year",
|
"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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||||
task_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
task_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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 class="grid gap-4 rounded-2xl bg-white p-5 shadow-soft md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
|
<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>
|
||||||
|
|
||||||
<div class="md:col-span-1 xl:col-span-2">
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
|
<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">
|
<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>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
<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">
|
<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>
|
<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>
|
<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 () {
|
(function () {
|
||||||
const form = document.getElementById('bulk-engagement-form');
|
const form = document.getElementById('bulk-engagement-form');
|
||||||
const service = document.getElementById('bulk-service');
|
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 info = document.getElementById('service-derived-info');
|
||||||
const reviewPartner = document.getElementById('bulk-review-partner');
|
const reviewPartner = document.getElementById('bulk-review-partner');
|
||||||
const reviewRequirement = document.getElementById('review-partner-requirement');
|
const reviewRequirement = document.getElementById('review-partner-requirement');
|
||||||
@@ -169,6 +185,21 @@
|
|||||||
reviewHelp.textContent = assurance
|
reviewHelp.textContent = assurance
|
||||||
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
|
? '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.';
|
: '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
|
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.`
|
? `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.';
|
: 'Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.';
|
||||||
@@ -199,6 +230,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
service.addEventListener('change', updateServiceInfo);
|
service.addEventListener('change', updateServiceInfo);
|
||||||
|
fy.addEventListener('input', updateServiceInfo);
|
||||||
|
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
|
||||||
search.addEventListener('input', updateVisibleRows);
|
search.addEventListener('input', updateVisibleRows);
|
||||||
clear.addEventListener('click', () => { search.value = ''; updateVisibleRows(); search.focus(); });
|
clear.addEventListener('click', () => { search.value = ''; updateVisibleRows(); search.focus(); });
|
||||||
selectAll.addEventListener('change', () => { visibleBoxes().forEach(box => { box.checked = selectAll.checked; }); updateCount(); });
|
selectAll.addEventListener('change', () => { visibleBoxes().forEach(box => { box.checked = selectAll.checked; }); updateCount(); });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</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 %}
|
{% 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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{% if mode == 'edit' %}
|
{% 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>
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</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' }}
|
{{ subscription.catalogue.service_name if subscription and subscription.catalogue else '-' }} · {{ 'Assurance' if subscription and subscription.engagement_type == 'assurance' else 'Non-Assurance' }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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>
|
<option value="">Select service</option>
|
||||||
{% for selection in enabled_services %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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">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">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>
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% 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 %}
|
{% 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.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-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-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-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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user