Add subscription registration scope mapping
This commit is contained in:
@@ -34,7 +34,8 @@ from app.modules.services.client_services import (
|
|||||||
recurrence_requires_period,
|
recurrence_requires_period,
|
||||||
review_partner_required_for_engagement,
|
review_partner_required_for_engagement,
|
||||||
)
|
)
|
||||||
from app.modules.clients.models import Client
|
from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch
|
||||||
|
from app.modules.registrations.models import ClientRegistration, RegistrationType
|
||||||
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
|
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
|
||||||
from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target
|
from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target
|
||||||
from app.modules.services.models import (
|
from app.modules.services.models import (
|
||||||
@@ -74,6 +75,85 @@ def _partner_scope_id(db, user) -> int | None:
|
|||||||
roles = set(get_user_roles(db, user.id))
|
roles = set(get_user_roles(db, user.id))
|
||||||
return int(user.id) if "Partner" in roles else None
|
return int(user.id) if "Partner" in roles else None
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_scope_context(db, *, tenant_id: int, plan: ClientServicePlan):
|
||||||
|
businesses = db.execute(
|
||||||
|
select(ClientBusinessUnit).where(
|
||||||
|
ClientBusinessUnit.tenant_id == tenant_id,
|
||||||
|
ClientBusinessUnit.client_id == plan.client_id,
|
||||||
|
ClientBusinessUnit.is_active.is_(True),
|
||||||
|
).order_by(
|
||||||
|
ClientBusinessUnit.is_primary.desc(),
|
||||||
|
ClientBusinessUnit.business_name,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
branches = db.execute(
|
||||||
|
select(ClientBranch).where(
|
||||||
|
ClientBranch.tenant_id == tenant_id,
|
||||||
|
ClientBranch.client_id == plan.client_id,
|
||||||
|
ClientBranch.is_active.is_(True),
|
||||||
|
).order_by(
|
||||||
|
ClientBranch.is_primary.desc(),
|
||||||
|
ClientBranch.branch_name,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
registration_rows = db.execute(
|
||||||
|
select(ClientRegistration, RegistrationType)
|
||||||
|
.join(
|
||||||
|
RegistrationType,
|
||||||
|
RegistrationType.id == ClientRegistration.registration_type_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ClientRegistration.tenant_id == tenant_id,
|
||||||
|
ClientRegistration.client_id == plan.client_id,
|
||||||
|
ClientRegistration.status.in_(("active", "valid", "registered")),
|
||||||
|
RegistrationType.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RegistrationType.code,
|
||||||
|
ClientRegistration.registration_number,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
registrations = []
|
||||||
|
registration_type_by_id = {}
|
||||||
|
for registration, registration_type in registration_rows:
|
||||||
|
registrations.append(registration)
|
||||||
|
registration_type_by_id[registration.id] = registration_type
|
||||||
|
|
||||||
|
business_by_id = {row.id: row for row in businesses}
|
||||||
|
branch_by_id = {row.id: row for row in branches}
|
||||||
|
registration_by_id = {row.id: row for row in registrations}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"businesses": businesses,
|
||||||
|
"branches": branches,
|
||||||
|
"registrations": registrations,
|
||||||
|
"registration_type_by_id": registration_type_by_id,
|
||||||
|
"scope_business": business_by_id.get(plan.business_unit_id),
|
||||||
|
"scope_branch": branch_by_id.get(plan.client_branch_id),
|
||||||
|
"scope_registration": registration_by_id.get(plan.registration_id),
|
||||||
|
"scope_registration_type": (
|
||||||
|
registration_type_by_id.get(plan.registration_id)
|
||||||
|
if plan.registration_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_key_for(scope_type: str, target_id: int) -> str:
|
||||||
|
prefixes = {
|
||||||
|
"client": "CLIENT",
|
||||||
|
"business_unit": "BUSINESS",
|
||||||
|
"client_branch": "BRANCH",
|
||||||
|
"registration": "REGISTRATION",
|
||||||
|
}
|
||||||
|
if scope_type not in prefixes:
|
||||||
|
raise ValueError("scope_type")
|
||||||
|
return f"{prefixes[scope_type]}:{int(target_id)}"
|
||||||
|
|
||||||
def _ctx(request, db, user, **extra):
|
def _ctx(request, db, user, **extra):
|
||||||
data = {
|
data = {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -522,6 +602,322 @@ def subscription_bulk_submit(
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}/scope")
|
||||||
|
def subscription_scope_edit_page(
|
||||||
|
request: Request,
|
||||||
|
plan_id: int,
|
||||||
|
saved: int = 0,
|
||||||
|
updated_engagements: int = 0,
|
||||||
|
error: str = "",
|
||||||
|
):
|
||||||
|
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)
|
||||||
|
plan = _load_plan(
|
||||||
|
db,
|
||||||
|
plan_id=plan_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
)
|
||||||
|
if not plan:
|
||||||
|
return RedirectResponse("/services/subscriptions", 303)
|
||||||
|
|
||||||
|
scope_context = _registration_scope_context(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
error_messages = {
|
||||||
|
"scope_type": "Select a valid subscription scope.",
|
||||||
|
"business_unit": "Select a valid Business Unit belonging to this client.",
|
||||||
|
"client_branch": "Select a valid Client Branch belonging to this client.",
|
||||||
|
"registration": "Select a valid active registration belonging to this client.",
|
||||||
|
"registration_type": "The selected registration type does not match the Service Catalogue requirement.",
|
||||||
|
"duplicate_plan": "Another subscription already exists for this service and selected scope.",
|
||||||
|
"duplicate_engagement": "An engagement already exists for the selected scope, financial year and period.",
|
||||||
|
"propagation": "Select a valid propagation option.",
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/services/templates/services/subscriptions/scope.html",
|
||||||
|
_ctx(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
title="Map Subscription Scope",
|
||||||
|
plan=plan,
|
||||||
|
saved=saved,
|
||||||
|
updated_engagements=updated_engagements,
|
||||||
|
error_message=error_messages.get(error, ""),
|
||||||
|
**scope_context,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plan_id}/scope")
|
||||||
|
def subscription_scope_edit_submit(
|
||||||
|
request: Request,
|
||||||
|
plan_id: int,
|
||||||
|
scope_type: str = Form(...),
|
||||||
|
business_unit_id: str = Form(""),
|
||||||
|
client_branch_id: str = Form(""),
|
||||||
|
registration_id: str = Form(""),
|
||||||
|
propagation_mode: str = Form("open_engagements"),
|
||||||
|
scope_change_reason: str = Form(""),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/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)
|
||||||
|
plan = _load_plan(
|
||||||
|
db,
|
||||||
|
plan_id=plan_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
branch_id=branch_id,
|
||||||
|
)
|
||||||
|
if not plan:
|
||||||
|
return RedirectResponse("/services/subscriptions", 303)
|
||||||
|
|
||||||
|
normalized_scope = (
|
||||||
|
scope_type or ""
|
||||||
|
).strip().lower().replace("-", "_").replace(" ", "_")
|
||||||
|
if normalized_scope not in {
|
||||||
|
"client",
|
||||||
|
"business_unit",
|
||||||
|
"client_branch",
|
||||||
|
"registration",
|
||||||
|
}:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=scope_type",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
if propagation_mode not in {"subscription_only", "open_engagements"}:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=propagation",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_business_id = _optional_int(business_unit_id)
|
||||||
|
selected_branch_id = _optional_int(client_branch_id)
|
||||||
|
selected_registration_id = _optional_int(registration_id)
|
||||||
|
|
||||||
|
business = None
|
||||||
|
client_branch = None
|
||||||
|
registration = None
|
||||||
|
registration_type = None
|
||||||
|
|
||||||
|
if selected_business_id:
|
||||||
|
business = db.get(ClientBusinessUnit, selected_business_id)
|
||||||
|
if (
|
||||||
|
not business
|
||||||
|
or business.tenant_id != tenant_id
|
||||||
|
or business.client_id != plan.client_id
|
||||||
|
or not business.is_active
|
||||||
|
):
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=business_unit",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_branch_id:
|
||||||
|
client_branch = db.get(ClientBranch, selected_branch_id)
|
||||||
|
if (
|
||||||
|
not client_branch
|
||||||
|
or client_branch.tenant_id != tenant_id
|
||||||
|
or client_branch.client_id != plan.client_id
|
||||||
|
or not client_branch.is_active
|
||||||
|
):
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=client_branch",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
selected_business_id = client_branch.business_unit_id
|
||||||
|
business = db.get(ClientBusinessUnit, selected_business_id)
|
||||||
|
|
||||||
|
if selected_registration_id:
|
||||||
|
registration = db.get(ClientRegistration, selected_registration_id)
|
||||||
|
if (
|
||||||
|
not registration
|
||||||
|
or registration.tenant_id != tenant_id
|
||||||
|
or registration.client_id != plan.client_id
|
||||||
|
or registration.status not in {"active", "valid", "registered"}
|
||||||
|
):
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=registration",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
registration_type = db.get(
|
||||||
|
RegistrationType,
|
||||||
|
registration.registration_type_id,
|
||||||
|
)
|
||||||
|
selected_business_id = (
|
||||||
|
registration.business_unit_id or selected_business_id
|
||||||
|
)
|
||||||
|
selected_branch_id = (
|
||||||
|
registration.client_branch_id or selected_branch_id
|
||||||
|
)
|
||||||
|
if selected_business_id:
|
||||||
|
business = db.get(ClientBusinessUnit, selected_business_id)
|
||||||
|
if selected_branch_id:
|
||||||
|
client_branch = db.get(ClientBranch, selected_branch_id)
|
||||||
|
|
||||||
|
if normalized_scope == "client":
|
||||||
|
target_id = plan.client_id
|
||||||
|
selected_business_id = None
|
||||||
|
selected_branch_id = None
|
||||||
|
selected_registration_id = None
|
||||||
|
elif normalized_scope == "business_unit":
|
||||||
|
if not business:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=business_unit",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
target_id = business.id
|
||||||
|
selected_branch_id = None
|
||||||
|
selected_registration_id = None
|
||||||
|
elif normalized_scope == "client_branch":
|
||||||
|
if not client_branch:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=client_branch",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
target_id = client_branch.id
|
||||||
|
selected_registration_id = None
|
||||||
|
else:
|
||||||
|
if not registration or not registration_type:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=registration",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
required_type = (
|
||||||
|
getattr(plan.catalogue, "required_registration_type", None)
|
||||||
|
or ""
|
||||||
|
).strip().upper()
|
||||||
|
actual_type = (registration_type.code or "").strip().upper()
|
||||||
|
if required_type and required_type != actual_type:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=registration_type",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
target_id = registration.id
|
||||||
|
|
||||||
|
new_scope_key = _scope_key_for(normalized_scope, target_id)
|
||||||
|
|
||||||
|
conflicting_plan = db.execute(
|
||||||
|
select(ClientServicePlan).where(
|
||||||
|
ClientServicePlan.tenant_id == tenant_id,
|
||||||
|
ClientServicePlan.service_catalogue_id
|
||||||
|
== plan.service_catalogue_id,
|
||||||
|
ClientServicePlan.scope_key == new_scope_key,
|
||||||
|
ClientServicePlan.id != plan.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if conflicting_plan:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=duplicate_plan",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
|
||||||
|
open_engagements = []
|
||||||
|
if propagation_mode == "open_engagements":
|
||||||
|
open_engagements = db.execute(
|
||||||
|
select(ClientServiceSubscription).where(
|
||||||
|
ClientServiceSubscription.tenant_id == tenant_id,
|
||||||
|
ClientServiceSubscription.service_plan_id == plan.id,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
open_engagements = [
|
||||||
|
row for row in open_engagements if _engagement_is_open(row)
|
||||||
|
]
|
||||||
|
|
||||||
|
for row in open_engagements:
|
||||||
|
conflict = db.execute(
|
||||||
|
select(ClientServiceSubscription.id).where(
|
||||||
|
ClientServiceSubscription.tenant_id == tenant_id,
|
||||||
|
ClientServiceSubscription.service_catalogue_id
|
||||||
|
== plan.service_catalogue_id,
|
||||||
|
ClientServiceSubscription.scope_key == new_scope_key,
|
||||||
|
ClientServiceSubscription.financial_year
|
||||||
|
== row.financial_year,
|
||||||
|
ClientServiceSubscription.period_label
|
||||||
|
== row.period_label,
|
||||||
|
ClientServiceSubscription.id != row.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if conflict:
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?error=duplicate_engagement",
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
|
||||||
|
old_scope = plan.scope_key or f"CLIENT:{plan.client_id}"
|
||||||
|
plan.scope_type = normalized_scope
|
||||||
|
plan.scope_key = new_scope_key
|
||||||
|
plan.business_unit_id = selected_business_id
|
||||||
|
plan.client_branch_id = selected_branch_id
|
||||||
|
plan.registration_id = selected_registration_id
|
||||||
|
plan.updated_by_user_id = user.id
|
||||||
|
plan.updated_at_utc = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
updated_engagements = 0
|
||||||
|
if propagation_mode == "open_engagements":
|
||||||
|
for row in open_engagements:
|
||||||
|
row.scope_type = normalized_scope
|
||||||
|
row.scope_key = new_scope_key
|
||||||
|
row.business_unit_id = selected_business_id
|
||||||
|
row.client_branch_id = selected_branch_id
|
||||||
|
row.registration_id = selected_registration_id
|
||||||
|
row.updated_by_user_id = user.id
|
||||||
|
row.updated_at_utc = datetime.now(timezone.utc)
|
||||||
|
if scope_change_reason.strip():
|
||||||
|
note = (
|
||||||
|
f"Subscription scope changed from {old_scope} "
|
||||||
|
f"to {new_scope_key} on "
|
||||||
|
f"{datetime.now(timezone.utc).date().isoformat()} "
|
||||||
|
f"by user {user.id}: "
|
||||||
|
f"{scope_change_reason.strip()}"
|
||||||
|
)
|
||||||
|
row.remarks = (
|
||||||
|
f"{row.remarks.strip()}\n{note}"
|
||||||
|
if row.remarks
|
||||||
|
else note
|
||||||
|
)
|
||||||
|
updated_engagements += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/services/subscriptions/{plan.id}/scope?saved=1"
|
||||||
|
f"&updated_engagements={updated_engagements}",
|
||||||
|
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,
|
||||||
@@ -796,6 +1192,11 @@ def subscription_master_detail(request: Request, plan_id: int):
|
|||||||
plan=plan,
|
plan=plan,
|
||||||
engagements=engagements,
|
engagements=engagements,
|
||||||
can_edit_subscription="clients.edit" in permissions,
|
can_edit_subscription="clients.edit" in permissions,
|
||||||
|
**_registration_scope_context(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plan=plan,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">
|
class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">
|
||||||
Edit Subscription
|
Edit Subscription
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}/scope"
|
||||||
|
class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||||
|
Map Scope
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/services/engagements/new?client_id={{ plan.client_id }}"
|
<a href="/services/engagements/new?client_id={{ plan.client_id }}"
|
||||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||||
@@ -33,6 +37,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Subscription Scope</h3>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
The legal client remains unchanged. Business Unit, Client Branch and Registration identify the exact compliance scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if can_edit_subscription %}
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}/scope"
|
||||||
|
class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||||
|
Change Scope
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Scope Type</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{{ (plan.scope_type or 'client')|replace('_', ' ')|title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Client</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">{{ plan.client.client_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Business Unit</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{{ scope_business.business_name if scope_business else '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Client Branch</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{{ scope_branch.branch_name if scope_branch else '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Registration Type</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{{ scope_registration_type.name if scope_registration_type else '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Registration Number</div>
|
||||||
|
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{{ scope_registration.registration_number if scope_registration else '-' }}
|
||||||
|
</div>
|
||||||
|
{% if scope_registration and scope_registration.trade_name %}
|
||||||
|
<div class="mt-1 text-xs text-slate-500">{{ scope_registration.trade_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h3 class="text-base font-semibold text-slate-900">Subscription Defaults</h3>
|
<h3 class="text-base font-semibold text-slate-900">Subscription Defaults</h3>
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
{% 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">Map Subscription Scope</h2>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
{{ plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700">
|
||||||
|
Back to Subscription
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
||||||
|
{{ error_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700">
|
||||||
|
Subscription scope updated. Open engagements updated: {{ updated_engagements }}.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<h3 class="text-base font-semibold text-slate-900">Current Scope</h3>
|
||||||
|
<div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="rounded-xl border p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">Scope Type</div>
|
||||||
|
<div class="mt-1 text-sm font-medium">{{ (plan.scope_type or 'client')|replace('_',' ')|title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">Business Unit</div>
|
||||||
|
<div class="mt-1 text-sm font-medium">{{ scope_business.business_name if scope_business else '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">Client Branch</div>
|
||||||
|
<div class="mt-1 text-sm font-medium">{{ scope_branch.branch_name if scope_branch else '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">Registration</div>
|
||||||
|
<div class="mt-1 text-sm font-medium">
|
||||||
|
{% if scope_registration_type and scope_registration %}
|
||||||
|
{{ scope_registration_type.code }} — {{ scope_registration.registration_number }}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="/services/subscriptions/{{ plan.id }}/scope"
|
||||||
|
class="space-y-5 rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">New Scope Type *</label>
|
||||||
|
<select name="scope_type" id="scope-type" required
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
{% for value,label in [
|
||||||
|
('client','Client / PAN level'),
|
||||||
|
('business_unit','Business Unit level'),
|
||||||
|
('client_branch','Client Branch level'),
|
||||||
|
('registration','Registration level')
|
||||||
|
] %}
|
||||||
|
<option value="{{ value }}" {% if (plan.scope_type or 'client') == value %}selected{% endif %}>
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="business-wrap">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Business Unit</label>
|
||||||
|
<select name="business_unit_id" id="business-unit"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select Business Unit</option>
|
||||||
|
{% for row in businesses %}
|
||||||
|
<option value="{{ row.id }}" {% if plan.business_unit_id == row.id %}selected{% endif %}>
|
||||||
|
{{ row.business_name }}{% if row.trade_name %} — {{ row.trade_name }}{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="branch-wrap">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Client Branch</label>
|
||||||
|
<select name="client_branch_id" id="client-branch"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select Client Branch</option>
|
||||||
|
{% for row in branches %}
|
||||||
|
<option value="{{ row.id }}"
|
||||||
|
data-business="{{ row.business_unit_id }}"
|
||||||
|
{% if plan.client_branch_id == row.id %}selected{% endif %}>
|
||||||
|
{{ row.branch_name }}{% if row.state %} — {{ row.state }}{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="registration-wrap">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Registration</label>
|
||||||
|
<select name="registration_id" id="registration"
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
<option value="">Select Registration</option>
|
||||||
|
{% for row in registrations %}
|
||||||
|
{% set registration_type = registration_type_by_id.get(row.id) %}
|
||||||
|
<option value="{{ row.id }}"
|
||||||
|
data-business="{{ row.business_unit_id or '' }}"
|
||||||
|
data-branch="{{ row.client_branch_id or '' }}"
|
||||||
|
data-type="{{ registration_type.code if registration_type else '' }}"
|
||||||
|
{% if plan.registration_id == row.id %}selected{% endif %}>
|
||||||
|
{{ registration_type.code if registration_type else 'Registration' }}
|
||||||
|
— {{ row.registration_number }}
|
||||||
|
{% if row.trade_name %} — {{ row.trade_name }}{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if plan.catalogue.required_registration_type %}
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Required by Service Catalogue: {{ plan.catalogue.required_registration_type }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Apply Scope Change To</label>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<label class="flex items-start gap-3 rounded-xl border p-4">
|
||||||
|
<input type="radio" name="propagation_mode" value="subscription_only" class="mt-1">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium">Subscription master only</span>
|
||||||
|
<span class="text-xs text-slate-500">
|
||||||
|
Future engagements use the new scope; existing engagements remain unchanged.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 rounded-xl border p-4">
|
||||||
|
<input type="radio" name="propagation_mode" value="open_engagements" checked class="mt-1">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium">Subscription and open engagements</span>
|
||||||
|
<span class="text-xs text-slate-500">
|
||||||
|
Updates active/unlocked engagements only. Completed, cancelled and locked history is preserved.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700">Reason for Scope Change</label>
|
||||||
|
<textarea name="scope_change_reason" rows="3" required
|
||||||
|
placeholder="Example: Existing GSTR-3B subscription mapped to IZI GST registration."
|
||||||
|
class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
|
This action does not change the Client, subscription ID, engagement IDs, due dates,
|
||||||
|
assignment team, tasks, evidence or review history.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<a href="/services/subscriptions/{{ plan.id }}"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm text-slate-700">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white">
|
||||||
|
Save Scope Mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const type = document.getElementById('scope-type');
|
||||||
|
const businessWrap = document.getElementById('business-wrap');
|
||||||
|
const branchWrap = document.getElementById('branch-wrap');
|
||||||
|
const registrationWrap = document.getElementById('registration-wrap');
|
||||||
|
const business = document.getElementById('business-unit');
|
||||||
|
const branch = document.getElementById('client-branch');
|
||||||
|
const registration = document.getElementById('registration');
|
||||||
|
|
||||||
|
function refresh(){
|
||||||
|
const value = type.value;
|
||||||
|
businessWrap.classList.toggle('hidden', value === 'client');
|
||||||
|
branchWrap.classList.toggle('hidden', !['client_branch','registration'].includes(value));
|
||||||
|
registrationWrap.classList.toggle('hidden', value !== 'registration');
|
||||||
|
business.required = value === 'business_unit';
|
||||||
|
branch.required = value === 'client_branch';
|
||||||
|
registration.required = value === 'registration';
|
||||||
|
}
|
||||||
|
|
||||||
|
branch.addEventListener('change', function(){
|
||||||
|
const option = branch.options[branch.selectedIndex];
|
||||||
|
if (option && option.dataset.business) {
|
||||||
|
business.value = option.dataset.business;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registration.addEventListener('change', function(){
|
||||||
|
const option = registration.options[registration.selectedIndex];
|
||||||
|
if (!option) return;
|
||||||
|
if (option.dataset.business) business.value = option.dataset.business;
|
||||||
|
if (option.dataset.branch) branch.value = option.dataset.branch;
|
||||||
|
});
|
||||||
|
|
||||||
|
type.addEventListener('change', refresh);
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user