Align bulk subscriptions with bulk engagement setup
This commit is contained in:
@@ -13,12 +13,29 @@ from app.core.security.session_auth import get_current_user
|
|||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.core.rbac.permission_guard import require_permission
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
|
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked
|
||||||
from app.modules.services.client_services import (
|
from app.modules.services.client_services import (
|
||||||
SUBSCRIPTION_STATUSES,
|
SUBSCRIPTION_STATUSES,
|
||||||
|
assessment_year_from_financial_year,
|
||||||
|
attach_engagement_to_plan,
|
||||||
|
ensure_engagement_quality_workflow,
|
||||||
|
enforce_quality_gate_on_subscription,
|
||||||
|
get_enabled_firm_service,
|
||||||
|
get_existing_subscription,
|
||||||
|
get_or_create_client_service_plan,
|
||||||
list_assignable_users,
|
list_assignable_users,
|
||||||
|
list_clients_for_assignment,
|
||||||
|
list_enabled_services_for_assignment,
|
||||||
list_review_partners,
|
list_review_partners,
|
||||||
|
normalize_financial_year,
|
||||||
|
normalize_period_label,
|
||||||
parse_date,
|
parse_date,
|
||||||
|
period_choices_for_service,
|
||||||
|
recurrence_requires_period,
|
||||||
|
review_partner_required_for_engagement,
|
||||||
)
|
)
|
||||||
|
from app.modules.clients.models import Client
|
||||||
|
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
|
||||||
from app.modules.services.models import (
|
from app.modules.services.models import (
|
||||||
ClientServicePlan,
|
ClientServicePlan,
|
||||||
ClientServiceSubscription,
|
ClientServiceSubscription,
|
||||||
@@ -44,6 +61,18 @@ def _branch_id(request, user):
|
|||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _active_financial_year(request: Request) -> str:
|
||||||
|
return normalize_financial_year(
|
||||||
|
request.session.get("active_financial_year")
|
||||||
|
or getattr(request.state, "year_code", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _partner_scope_id(db, user) -> int | None:
|
||||||
|
roles = set(get_user_roles(db, user.id))
|
||||||
|
return int(user.id) if "Partner" in roles else None
|
||||||
|
|
||||||
def _ctx(request, db, user, **extra):
|
def _ctx(request, db, user, **extra):
|
||||||
data = {
|
data = {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -189,6 +218,353 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bulk")
|
||||||
|
def subscription_bulk_page(
|
||||||
|
request: Request,
|
||||||
|
error: str = "",
|
||||||
|
subscriptions_created: int = 0,
|
||||||
|
subscriptions_reused: int = 0,
|
||||||
|
engagements_created: int = 0,
|
||||||
|
engagements_skipped: int = 0,
|
||||||
|
):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
try:
|
||||||
|
require_permission(db, user, "clients.edit")
|
||||||
|
except Exception:
|
||||||
|
return _denied()
|
||||||
|
|
||||||
|
tenant_id = _tenant_id(request, user)
|
||||||
|
branch_id = _branch_id(request, user)
|
||||||
|
clients = list_clients_for_assignment(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
partner_id=_partner_scope_id(db, user),
|
||||||
|
)
|
||||||
|
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||||
|
partners = list_assignable_users(
|
||||||
|
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",)
|
||||||
|
)
|
||||||
|
managers = list_assignable_users(
|
||||||
|
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",)
|
||||||
|
)
|
||||||
|
staff_users = list_assignable_users(
|
||||||
|
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",)
|
||||||
|
)
|
||||||
|
review_partners = list_review_partners(
|
||||||
|
db, tenant_id=tenant_id, branch_id=branch_id
|
||||||
|
)
|
||||||
|
error_messages = {
|
||||||
|
"clients": "Select at least one permitted client.",
|
||||||
|
"service": "Select a valid enabled firm service.",
|
||||||
|
"partner": "Select a valid Engagement Partner.",
|
||||||
|
"performing_partner": "Select a valid Performing Partner.",
|
||||||
|
"manager": "Select a valid Manager.",
|
||||||
|
"staff": "Select a valid Staff member.",
|
||||||
|
"review_partner": "Select a valid Review Partner.",
|
||||||
|
"review_partner_required": "Review Partner is mandatory for this assurance service.",
|
||||||
|
"review_partner_independence": "Review Partner must differ from the Engagement and Performing Partners.",
|
||||||
|
"period": "Select a valid month or quarter.",
|
||||||
|
"generation": "Select a valid engagement generation option.",
|
||||||
|
"financial_year": "The selected financial year is locked.",
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/services/templates/services/subscriptions/bulk.html",
|
||||||
|
_ctx(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
title="Bulk Client Subscriptions",
|
||||||
|
clients=clients,
|
||||||
|
enabled_services=enabled_services,
|
||||||
|
partners=partners,
|
||||||
|
managers=managers,
|
||||||
|
staff_users=staff_users,
|
||||||
|
review_partners=review_partners,
|
||||||
|
client_partner_names={
|
||||||
|
row.id: (row.full_name or row.email)
|
||||||
|
for row in partners
|
||||||
|
},
|
||||||
|
financial_year=_active_financial_year(request),
|
||||||
|
error_message=error_messages.get(error, ""),
|
||||||
|
subscriptions_created=subscriptions_created,
|
||||||
|
subscriptions_reused=subscriptions_reused,
|
||||||
|
engagements_created=engagements_created,
|
||||||
|
engagements_skipped=engagements_skipped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk")
|
||||||
|
def subscription_bulk_submit(
|
||||||
|
request: Request,
|
||||||
|
client_ids: list[int] = Form([]),
|
||||||
|
service_catalogue_id: int = Form(...),
|
||||||
|
default_partner_user_id: int = Form(...),
|
||||||
|
default_performing_partner_user_id: str = Form(""),
|
||||||
|
default_manager_user_id: str = Form(""),
|
||||||
|
default_staff_user_id: str = Form(""),
|
||||||
|
default_review_partner_user_id: str = Form(""),
|
||||||
|
effective_from: str = Form(""),
|
||||||
|
effective_to: str = Form(""),
|
||||||
|
auto_generate_periods: str | None = Form(None),
|
||||||
|
remarks: str = Form(""),
|
||||||
|
generation_mode: str = Form("subscription_only"),
|
||||||
|
financial_year: str = Form(""),
|
||||||
|
period_label: str = Form(""),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
subscriptions_created = 0
|
||||||
|
subscriptions_reused = 0
|
||||||
|
engagements_created = 0
|
||||||
|
engagements_skipped = 0
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
try:
|
||||||
|
require_permission(db, user, "clients.edit")
|
||||||
|
except Exception:
|
||||||
|
return _denied()
|
||||||
|
|
||||||
|
tenant_id = _tenant_id(request, user)
|
||||||
|
branch_id = _branch_id(request, user)
|
||||||
|
permitted_clients = list_clients_for_assignment(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
partner_id=_partner_scope_id(db, user),
|
||||||
|
)
|
||||||
|
permitted_client_map = {row.id: row for row in permitted_clients}
|
||||||
|
selected_client_ids = list(dict.fromkeys(int(value) for value in client_ids))
|
||||||
|
if not selected_client_ids or any(value not in permitted_client_map for value in selected_client_ids):
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=clients", 303)
|
||||||
|
|
||||||
|
firm_selection = get_enabled_firm_service(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_catalogue_id=service_catalogue_id,
|
||||||
|
)
|
||||||
|
if not firm_selection:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=service", 303)
|
||||||
|
|
||||||
|
plan_branch_id = (
|
||||||
|
branch_id
|
||||||
|
or getattr(firm_selection, "default_branch_id", None)
|
||||||
|
or getattr(user, "branch_id", None)
|
||||||
|
)
|
||||||
|
partners = list_assignable_users(
|
||||||
|
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",)
|
||||||
|
)
|
||||||
|
managers = list_assignable_users(
|
||||||
|
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",)
|
||||||
|
)
|
||||||
|
staff_users = list_assignable_users(
|
||||||
|
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",)
|
||||||
|
)
|
||||||
|
review_partners = list_review_partners(
|
||||||
|
db, tenant_id=tenant_id, branch_id=plan_branch_id
|
||||||
|
)
|
||||||
|
|
||||||
|
partner_ids = {row.id for row in partners}
|
||||||
|
manager_ids = {row.id for row in managers}
|
||||||
|
staff_ids = {row.id for row in staff_users}
|
||||||
|
review_partner_ids = {row.id for row in review_partners}
|
||||||
|
|
||||||
|
if default_partner_user_id not in partner_ids:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=partner", 303)
|
||||||
|
performing_partner_id = _optional_int(default_performing_partner_user_id) or default_partner_user_id
|
||||||
|
if performing_partner_id not in partner_ids:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=performing_partner", 303)
|
||||||
|
manager_id = _optional_int(default_manager_user_id)
|
||||||
|
if manager_id is not None and manager_id not in manager_ids:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=manager", 303)
|
||||||
|
staff_id = _optional_int(default_staff_user_id)
|
||||||
|
if staff_id is not None and staff_id not in staff_ids:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=staff", 303)
|
||||||
|
review_partner_id = _optional_int(default_review_partner_user_id)
|
||||||
|
if review_partner_id is not None and review_partner_id not in review_partner_ids:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner", 303)
|
||||||
|
|
||||||
|
engagement_type = (
|
||||||
|
getattr(firm_selection.catalogue, "engagement_type", None)
|
||||||
|
or "non_assurance"
|
||||||
|
)
|
||||||
|
review_required = review_partner_required_for_engagement(
|
||||||
|
db, tenant_id=tenant_id, engagement_type=engagement_type
|
||||||
|
)
|
||||||
|
if review_required and review_partner_id is None:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_required", 303)
|
||||||
|
if review_required and review_partner_id in {default_partner_user_id, performing_partner_id}:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_independence", 303)
|
||||||
|
|
||||||
|
if generation_mode not in {"subscription_only", "current_period", "all_periods"}:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=generation", 303)
|
||||||
|
|
||||||
|
selected_financial_year = normalize_financial_year(
|
||||||
|
financial_year or _active_financial_year(request)
|
||||||
|
)
|
||||||
|
if generation_mode != "subscription_only":
|
||||||
|
locked_response = redirect_if_financial_year_locked(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
year_code=selected_financial_year,
|
||||||
|
redirect_url="/services/subscriptions/bulk?error=financial_year",
|
||||||
|
)
|
||||||
|
if locked_response:
|
||||||
|
return locked_response
|
||||||
|
|
||||||
|
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
|
||||||
|
if generation_mode == "subscription_only":
|
||||||
|
requested_periods: list[str] = []
|
||||||
|
elif recurrence_requires_period(recurrence_type):
|
||||||
|
if generation_mode == "all_periods":
|
||||||
|
requested_periods = [
|
||||||
|
code for code, _label in period_choices_for_service(
|
||||||
|
selected_financial_year, recurrence_type
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
requested_periods = [
|
||||||
|
normalize_period_label(
|
||||||
|
period_label,
|
||||||
|
financial_year=selected_financial_year,
|
||||||
|
recurrence_type=recurrence_type,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except ValueError:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
||||||
|
else:
|
||||||
|
requested_periods = [""]
|
||||||
|
|
||||||
|
plan_effective_from = parse_date(effective_from)
|
||||||
|
plan_effective_to = parse_date(effective_to)
|
||||||
|
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
||||||
|
|
||||||
|
for client_id in selected_client_ids:
|
||||||
|
client = permitted_client_map[client_id]
|
||||||
|
existing_plan = db.execute(
|
||||||
|
select(ClientServicePlan).where(
|
||||||
|
ClientServicePlan.tenant_id == tenant_id,
|
||||||
|
ClientServicePlan.client_id == client.id,
|
||||||
|
ClientServicePlan.service_catalogue_id == service_catalogue_id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
plan = get_or_create_client_service_plan(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
client=client,
|
||||||
|
catalogue=firm_selection.catalogue,
|
||||||
|
firm_selection=firm_selection,
|
||||||
|
branch_id=plan_branch_id or getattr(client, "branch_id", None),
|
||||||
|
partner_user_id=default_partner_user_id,
|
||||||
|
performing_partner_user_id=performing_partner_id,
|
||||||
|
manager_user_id=manager_id,
|
||||||
|
staff_user_id=staff_id,
|
||||||
|
review_partner_user_id=review_partner_id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
remarks=remarks.strip() or None,
|
||||||
|
)
|
||||||
|
plan.effective_from = plan_effective_from
|
||||||
|
plan.effective_to = plan_effective_to
|
||||||
|
plan.auto_generate_periods = auto_generate_periods is not None
|
||||||
|
plan.status = "active"
|
||||||
|
plan.is_active = True
|
||||||
|
plan.updated_by_user_id = user.id
|
||||||
|
if remarks.strip():
|
||||||
|
plan.remarks = remarks.strip()
|
||||||
|
|
||||||
|
if existing_plan is None:
|
||||||
|
subscriptions_created += 1
|
||||||
|
else:
|
||||||
|
subscriptions_reused += 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:
|
||||||
|
if existing.service_plan_id is None:
|
||||||
|
existing.service_plan_id = plan.id
|
||||||
|
engagements_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
engagement = ClientServiceSubscription(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=plan.branch_id or getattr(client, "branch_id", None),
|
||||||
|
service_plan_id=plan.id,
|
||||||
|
client_id=client.id,
|
||||||
|
service_catalogue_id=service_catalogue_id,
|
||||||
|
firm_service_selection_id=firm_selection.id,
|
||||||
|
assigned_partner_user_id=default_partner_user_id,
|
||||||
|
performing_partner_user_id=performing_partner_id,
|
||||||
|
assigned_manager_user_id=manager_id,
|
||||||
|
assigned_staff_user_id=staff_id,
|
||||||
|
review_partner_user_id=review_partner_id if review_required 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,
|
||||||
|
start_date=plan_effective_from,
|
||||||
|
end_date=plan_effective_to,
|
||||||
|
status="active",
|
||||||
|
remarks=remarks.strip() or None,
|
||||||
|
is_active=True,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
updated_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(engagement)
|
||||||
|
db.flush()
|
||||||
|
attach_engagement_to_plan(
|
||||||
|
db,
|
||||||
|
engagement=engagement,
|
||||||
|
client=client,
|
||||||
|
catalogue=firm_selection.catalogue,
|
||||||
|
firm_selection=firm_selection,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
)
|
||||||
|
apply_due_date_rule_to_subscription(db, engagement, force=True)
|
||||||
|
ensure_engagement_quality_workflow(
|
||||||
|
db,
|
||||||
|
subscription=engagement,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
create_declarations=False,
|
||||||
|
)
|
||||||
|
enforce_quality_gate_on_subscription(engagement)
|
||||||
|
engagements_created += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(
|
||||||
|
"/services/subscriptions/bulk"
|
||||||
|
f"?subscriptions_created={subscriptions_created}"
|
||||||
|
f"&subscriptions_reused={subscriptions_reused}"
|
||||||
|
f"&engagements_created={engagements_created}"
|
||||||
|
f"&engagements_skipped={engagements_skipped}",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
@router.get("/{plan_id}/edit")
|
@router.get("/{plan_id}/edit")
|
||||||
def subscription_master_edit_page(
|
def subscription_master_edit_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
|
||||||
|
{% if 'partner' in _uiux_partner_role_text %}
|
||||||
|
{% include "ui/templates/components/partner_navigation_v2.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Bulk Subscription Setup</h2>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
Choose one enabled firm service, create the subscription for multiple clients, and optionally generate period-wise engagements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/services/subscriptions"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-700">
|
||||||
|
{{ error_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subscriptions Created</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_created }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Existing Reused</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_reused }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Engagements Created</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ engagements_created }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Duplicates Skipped</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ engagements_skipped }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/services/subscriptions/bulk" id="bulk-subscription-form" class="space-y-5">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<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"
|
||||||
|
id="bulk-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">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Enabled Firm Service</label>
|
||||||
|
<select name="service_catalogue_id"
|
||||||
|
id="bulk-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 }}"
|
||||||
|
data-type="{{ selection.catalogue.engagement_type or 'non_assurance' }}"
|
||||||
|
data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">
|
||||||
|
{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Required only when generating one monthly or quarterly engagement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
|
||||||
|
<select name="default_partner_user_id"
|
||||||
|
id="bulk-partner"
|
||||||
|
required
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
<option value="">Select partner</option>
|
||||||
|
{% for u in partners %}
|
||||||
|
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
The subscription branch is derived from the selected Partner and active branch scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Performing Partner</label>
|
||||||
|
<select name="default_performing_partner_user_id"
|
||||||
|
id="bulk-performing-partner"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
<option value="">Same as Engagement Partner</option>
|
||||||
|
{% for u in partners %}
|
||||||
|
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Defaults to the Engagement Partner when left blank.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Default Manager</label>
|
||||||
|
<select name="default_manager_user_id"
|
||||||
|
id="bulk-manager"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
{% for u in managers %}
|
||||||
|
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Default Staff</label>
|
||||||
|
<select name="default_staff_user_id"
|
||||||
|
id="bulk-staff"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
{% for u in staff_users %}
|
||||||
|
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="review-partner-field">
|
||||||
|
<label class="mb-2 flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
|
<span>Review Partner</span>
|
||||||
|
<span id="review-partner-requirement"
|
||||||
|
class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600">
|
||||||
|
Optional
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select name="default_review_partner_user_id"
|
||||||
|
id="bulk-review-partner"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
{% for u in review_partners %}
|
||||||
|
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p id="review-partner-help" class="mt-1 text-xs text-slate-500">
|
||||||
|
Optional for non-assurance services. The selected Review Partner becomes the subscription default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Effective From</label>
|
||||||
|
<input type="date"
|
||||||
|
name="effective_from"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Effective To</label>
|
||||||
|
<input type="date"
|
||||||
|
name="effective_to"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 xl:col-span-3">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700">Remarks applied to all subscriptions and generated engagements</label>
|
||||||
|
<textarea name="remarks"
|
||||||
|
rows="2"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Engagement Generation</h3>
|
||||||
|
<div class="mt-4 grid gap-3 lg:grid-cols-3">
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||||
|
<input type="radio"
|
||||||
|
name="generation_mode"
|
||||||
|
value="subscription_only"
|
||||||
|
checked
|
||||||
|
class="mt-1 border-slate-300">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-900">Subscriptions only</span>
|
||||||
|
<span class="block text-xs text-slate-500">Create or reuse the client-service master without generating engagement instances.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||||
|
<input type="radio"
|
||||||
|
name="generation_mode"
|
||||||
|
value="current_period"
|
||||||
|
class="mt-1 border-slate-300">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-900">One engagement</span>
|
||||||
|
<span class="block text-xs text-slate-500">Generate one selected month, quarter, annual, or one-time engagement for each client.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||||
|
<input type="radio"
|
||||||
|
name="generation_mode"
|
||||||
|
value="all_periods"
|
||||||
|
class="mt-1 border-slate-300">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-900">All FY engagements</span>
|
||||||
|
<span class="block text-xs text-slate-500">Generate 12 monthly, 4 quarterly, or one annual/one-time engagement for each client.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="auto_generate_periods"
|
||||||
|
value="1"
|
||||||
|
class="rounded border-slate-300">
|
||||||
|
Keep automatic period generation enabled on the subscription
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3 border-b border-slate-200 p-4">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search clients</label>
|
||||||
|
<input type="search"
|
||||||
|
id="client-search"
|
||||||
|
placeholder="Code, name, PAN, GSTIN or type"
|
||||||
|
class="w-80 max-w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
id="clear-client-search"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-600">
|
||||||
|
<span id="selected-client-count" class="font-semibold text-slate-900">0</span> clients selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[32rem] overflow-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
|
<thead class="sticky top-0 z-10 bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 px-4 py-3 text-left">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="select-all-visible"
|
||||||
|
class="rounded border-slate-300"
|
||||||
|
title="Select all visible clients">
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">PAN / GSTIN</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Current Partner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="client-table-body" class="divide-y divide-slate-100">
|
||||||
|
{% for client in clients %}
|
||||||
|
{% set search_text = ((client.client_code or '') ~ ' ' ~ (client.client_name or '') ~ ' ' ~ (client.trade_name or '') ~ ' ' ~ (client.pan or '') ~ ' ' ~ (client.gstin or '') ~ ' ' ~ (client.client_type or ''))|lower %}
|
||||||
|
<tr class="client-row" data-search="{{ search_text|e }}">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="client_ids"
|
||||||
|
value="{{ client.id }}"
|
||||||
|
class="client-checkbox rounded border-slate-300">
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-medium text-slate-700">{{ client.client_code }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
<div class="font-medium text-slate-900">{{ client.client_name }}</div>
|
||||||
|
{% if client.trade_name %}
|
||||||
|
<div class="text-xs text-slate-500">{{ client.trade_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600">
|
||||||
|
<div>PAN: {{ client.pan or '-' }}</div>
|
||||||
|
<div>GSTIN: {{ client.gstin or '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-slate-600">{{ client.client_type or '-' }}</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600">
|
||||||
|
{{ client_partner_names.get(client.partner_id, '-') if client.partner_id else '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">
|
||||||
|
No clients are available for subscription.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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">
|
||||||
|
<p class="text-sm text-slate-600">
|
||||||
|
Existing client-service subscriptions are reused. Existing engagements for the same client, service, financial year and period are skipped automatically.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/services/subscriptions"
|
||||||
|
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-subscriptions-button"
|
||||||
|
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||||
|
Create Subscriptions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('bulk-subscription-form');
|
||||||
|
const service = document.getElementById('bulk-service');
|
||||||
|
const info = document.getElementById('service-derived-info');
|
||||||
|
const reviewPartner = document.getElementById('bulk-review-partner');
|
||||||
|
const reviewRequirement = document.getElementById('review-partner-requirement');
|
||||||
|
const reviewHelp = document.getElementById('review-partner-help');
|
||||||
|
const financialYear = document.getElementById('bulk-financial-year');
|
||||||
|
const period = document.getElementById('bulk-period');
|
||||||
|
const generationRadios = Array.from(document.querySelectorAll('input[name="generation_mode"]'));
|
||||||
|
const search = document.getElementById('client-search');
|
||||||
|
const clear = document.getElementById('clear-client-search');
|
||||||
|
const selectAll = document.getElementById('select-all-visible');
|
||||||
|
const count = document.getElementById('selected-client-count');
|
||||||
|
const rows = Array.from(document.querySelectorAll('.client-row'));
|
||||||
|
const boxes = Array.from(document.querySelectorAll('.client-checkbox'));
|
||||||
|
|
||||||
|
function currentGenerationMode() {
|
||||||
|
const selected = generationRadios.find(radio => radio.checked);
|
||||||
|
return selected ? selected.value : 'subscription_only';
|
||||||
|
}
|
||||||
|
|
||||||
|
function financialYearParts() {
|
||||||
|
const raw = (financialYear.value || '').trim();
|
||||||
|
const start = parseInt(raw.split('-')[0], 10);
|
||||||
|
return Number.isFinite(start)
|
||||||
|
? [start, start + 1]
|
||||||
|
: [new Date().getFullYear(), new Date().getFullYear() + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildPeriodOptions() {
|
||||||
|
const option = service.options[service.selectedIndex];
|
||||||
|
const recurrence = option ? (option.dataset.recurrence || '').trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_') : '';
|
||||||
|
const mode = currentGenerationMode();
|
||||||
|
const requiresSelectedPeriod = mode === 'current_period' && (recurrence === 'monthly' || recurrence === 'quarterly');
|
||||||
|
|
||||||
|
period.innerHTML = '<option value="">Not applicable</option>';
|
||||||
|
period.required = requiresSelectedPeriod;
|
||||||
|
period.disabled = mode !== 'current_period';
|
||||||
|
|
||||||
|
if (!requiresSelectedPeriod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startYear, endYear] = financialYearParts();
|
||||||
|
|
||||||
|
if (recurrence === 'monthly') {
|
||||||
|
const months = [
|
||||||
|
[4, 'Apr'], [5, 'May'], [6, 'Jun'], [7, 'Jul'],
|
||||||
|
[8, 'Aug'], [9, 'Sep'], [10, 'Oct'], [11, 'Nov'],
|
||||||
|
[12, 'Dec'], [1, 'Jan'], [2, 'Feb'], [3, 'Mar']
|
||||||
|
];
|
||||||
|
months.forEach(([month, label]) => {
|
||||||
|
const year = month >= 4 ? startYear : endYear;
|
||||||
|
const value = `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
period.add(new Option(`${label} ${year}`, value));
|
||||||
|
});
|
||||||
|
} else if (recurrence === 'quarterly') {
|
||||||
|
['Q1', 'Q2', 'Q3', 'Q4'].forEach(quarter => {
|
||||||
|
period.add(new Option(`${quarter} ${financialYear.value}`, quarter));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateServiceInfo() {
|
||||||
|
const option = service.options[service.selectedIndex];
|
||||||
|
const type = option ? (option.dataset.type || '') : '';
|
||||||
|
const recurrence = option ? (option.dataset.recurrence || '') : '';
|
||||||
|
const normalizedType = type.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
||||||
|
const assurance = normalizedType === 'assurance';
|
||||||
|
|
||||||
|
reviewPartner.required = assurance;
|
||||||
|
reviewRequirement.textContent = assurance ? 'Mandatory' : 'Optional';
|
||||||
|
reviewRequirement.className = assurance
|
||||||
|
? 'rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700'
|
||||||
|
: 'rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600';
|
||||||
|
reviewHelp.textContent = assurance
|
||||||
|
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
|
||||||
|
: 'Optional for non-assurance services. The selected Review Partner becomes the subscription default.';
|
||||||
|
|
||||||
|
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.';
|
||||||
|
|
||||||
|
rebuildPeriodOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibleRows() {
|
||||||
|
const term = (search.value || '').trim().toLowerCase();
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.classList.toggle('hidden', term && !row.dataset.search.includes(term));
|
||||||
|
});
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleBoxes() {
|
||||||
|
return rows
|
||||||
|
.filter(row => !row.classList.contains('hidden'))
|
||||||
|
.map(row => row.querySelector('.client-checkbox'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
count.textContent = String(boxes.filter(box => box.checked).length);
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const visible = visibleBoxes();
|
||||||
|
const checked = visible.filter(box => box.checked).length;
|
||||||
|
selectAll.checked = visible.length > 0 && checked === visible.length;
|
||||||
|
selectAll.indeterminate = checked > 0 && checked < visible.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
service.addEventListener('change', updateServiceInfo);
|
||||||
|
financialYear.addEventListener('change', rebuildPeriodOptions);
|
||||||
|
generationRadios.forEach(radio => radio.addEventListener('change', rebuildPeriodOptions));
|
||||||
|
search.addEventListener('input', updateVisibleRows);
|
||||||
|
clear.addEventListener('click', () => {
|
||||||
|
search.value = '';
|
||||||
|
updateVisibleRows();
|
||||||
|
search.focus();
|
||||||
|
});
|
||||||
|
selectAll.addEventListener('change', () => {
|
||||||
|
visibleBoxes().forEach(box => {
|
||||||
|
box.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
boxes.forEach(box => box.addEventListener('change', updateCount));
|
||||||
|
|
||||||
|
form.addEventListener('submit', event => {
|
||||||
|
const selected = boxes.filter(box => box.checked).length;
|
||||||
|
if (!selected) {
|
||||||
|
event.preventDefault();
|
||||||
|
alert('Select at least one client.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = currentGenerationMode();
|
||||||
|
let message = `Create or reuse subscriptions for ${selected} selected client${selected === 1 ? '' : 's'}`;
|
||||||
|
if (mode === 'current_period') {
|
||||||
|
message += ' and generate one engagement for each?';
|
||||||
|
} else if (mode === 'all_periods') {
|
||||||
|
message += ' and generate all applicable FY engagements?';
|
||||||
|
} else {
|
||||||
|
message += '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateServiceInfo();
|
||||||
|
updateCount();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -82,7 +82,12 @@
|
|||||||
{
|
{
|
||||||
'label': 'Client Subscriptions',
|
'label': 'Client Subscriptions',
|
||||||
'url': '/services/subscriptions',
|
'url': '/services/subscriptions',
|
||||||
'active': _partner_path.startswith('/services/subscriptions')
|
'active': _partner_path == '/services/subscriptions' or (_partner_path.startswith('/services/subscriptions/') and not _partner_path.startswith('/services/subscriptions/bulk'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Bulk Subscriptions',
|
||||||
|
'url': '/services/subscriptions/bulk',
|
||||||
|
'active': _partner_path.startswith('/services/subscriptions/bulk')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'All Engagements',
|
'label': 'All Engagements',
|
||||||
|
|||||||
Reference in New Issue
Block a user