Add controlled client subscription team reassignment

This commit is contained in:
A R R R Associates
2026-08-05 15:09:32 +05:30
parent 956560850d
commit 6a6ca2a4a7
3 changed files with 577 additions and 35 deletions
+427 -29
View File
@@ -1,22 +1,41 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Request from datetime import date, datetime, timezone
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user from app.core.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.services.models import ClientServicePlan, ClientServiceSubscription from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES,
list_assignable_users,
list_review_partners,
parse_date,
)
from app.modules.services.models import (
ClientServicePlan,
ClientServiceSubscription,
ClientServiceTaskInstance,
)
router = APIRouter(prefix="/services/subscriptions", tags=["client-service-subscriptions-ui"]) router = APIRouter(prefix="/services/subscriptions", tags=["client-service-subscriptions-ui"])
def _tenant_id(request, user): def _tenant_id(request, user):
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id) return int(
request.session.get("active_tenant_id")
or request.session.get("selected_tenant_id")
or request.session.get("tenant_id")
or user.tenant_id
)
def _branch_id(request, user): def _branch_id(request, user):
value = request.session.get("active_branch_id") value = request.session.get("active_branch_id")
@@ -24,47 +43,426 @@ def _branch_id(request, user):
return int(getattr(user, "branch_id", 0) or 0) or None return int(getattr(user, "branch_id", 0) or 0) or None
return int(value) return int(value)
def _ctx(request, db, user, **extra): def _ctx(request, db, user, **extra):
data={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)} data = {
data.update(extra); return data "request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"subscription_statuses": SUBSCRIPTION_STATUSES,
}
data.update(extra)
return data
def _denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _load_plan(db, *, plan_id: int, tenant_id: int, branch_id: int | None):
stmt = (
select(ClientServicePlan)
.options(
selectinload(ClientServicePlan.client),
selectinload(ClientServicePlan.catalogue),
selectinload(ClientServicePlan.default_partner),
selectinload(ClientServicePlan.default_performing_partner),
selectinload(ClientServicePlan.default_manager),
selectinload(ClientServicePlan.default_staff),
selectinload(ClientServicePlan.default_review_partner),
)
.where(
ClientServicePlan.id == plan_id,
ClientServicePlan.tenant_id == tenant_id,
)
)
if branch_id:
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
return db.execute(stmt).scalar_one_or_none()
def _optional_int(value: str) -> int | None:
value = (value or "").strip()
return int(value) if value else None
def _allowed_id(value: int | None, allowed: set[int], field_name: str) -> int | None:
if value is not None and value not in allowed:
raise ValueError(field_name)
return value
def _engagement_is_open(row: ClientServiceSubscription) -> bool:
return (
not bool(getattr(row, "is_locked", False))
and (getattr(row, "status", "") or "").lower() not in {"completed", "cancelled", "inactive"}
)
def _engagement_is_effective(row: ClientServiceSubscription, effective_from: date | None) -> bool:
if not effective_from:
return True
comparison_date = (
getattr(row, "start_date", None)
or getattr(row, "current_due_date", None)
or getattr(row, "original_due_date", None)
)
return comparison_date is None or comparison_date >= effective_from
@router.get("") @router.get("")
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False): def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
db = CommonSessionLocal() db = CommonSessionLocal()
try: try:
user = get_current_user(request, db=db) user = get_current_user(request, db=db)
if not user: return RedirectResponse("/login",303) if not user:
try: require_permission(db,user,"clients.view") return RedirectResponse("/login", 303)
try:
require_permission(db, user, "clients.view")
except Exception: except Exception:
from app.core.http_responses import ui_access_denied return _denied()
return ui_access_denied()
tenant_id=_tenant_id(request,user); branch_id=_branch_id(request,user) tenant_id = _tenant_id(request, user)
counts=(select(ClientServiceSubscription.service_plan_id, func.count(ClientServiceSubscription.id).label("engagement_count"), func.max(ClientServiceSubscription.current_due_date).label("latest_due_date")).where(ClientServiceSubscription.service_plan_id.is_not(None)).group_by(ClientServiceSubscription.service_plan_id).subquery()) branch_id = _branch_id(request, user)
stmt=(select(ClientServicePlan, counts.c.engagement_count, counts.c.latest_due_date).options(selectinload(ClientServicePlan.client),selectinload(ClientServicePlan.catalogue),selectinload(ClientServicePlan.default_partner),selectinload(ClientServicePlan.default_performing_partner),selectinload(ClientServicePlan.default_manager),selectinload(ClientServicePlan.default_staff),selectinload(ClientServicePlan.default_review_partner)).outerjoin(counts,counts.c.service_plan_id==ClientServicePlan.id).where(ClientServicePlan.tenant_id==tenant_id)) counts = (
if branch_id: stmt=stmt.where(ClientServicePlan.branch_id==branch_id) select(
if not include_inactive: stmt=stmt.where(ClientServicePlan.is_active.is_(True)) ClientServiceSubscription.service_plan_id,
func.count(ClientServiceSubscription.id).label("engagement_count"),
func.max(ClientServiceSubscription.current_due_date).label("latest_due_date"),
)
.where(ClientServiceSubscription.service_plan_id.is_not(None))
.group_by(ClientServiceSubscription.service_plan_id)
.subquery()
)
stmt = (
select(ClientServicePlan, counts.c.engagement_count, counts.c.latest_due_date)
.options(
selectinload(ClientServicePlan.client),
selectinload(ClientServicePlan.catalogue),
selectinload(ClientServicePlan.default_partner),
selectinload(ClientServicePlan.default_performing_partner),
selectinload(ClientServicePlan.default_manager),
selectinload(ClientServicePlan.default_staff),
selectinload(ClientServicePlan.default_review_partner),
)
.outerjoin(counts, counts.c.service_plan_id == ClientServicePlan.id)
.where(ClientServicePlan.tenant_id == tenant_id)
)
if branch_id:
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
if not include_inactive:
stmt = stmt.where(ClientServicePlan.is_active.is_(True))
if q.strip(): if q.strip():
from app.modules.clients.models import Client from app.modules.clients.models import Client
from app.modules.services.models import ServiceCatalogue from app.modules.services.models import ServiceCatalogue
term = f"%{q.strip()}%" term = f"%{q.strip()}%"
stmt=stmt.join(Client,Client.id==ClientServicePlan.client_id).join(ServiceCatalogue,ServiceCatalogue.id==ClientServicePlan.service_catalogue_id).where((Client.client_name.ilike(term)) | (Client.client_code.ilike(term)) | (ServiceCatalogue.service_name.ilike(term)) | (ServiceCatalogue.service_code.ilike(term))) stmt = (
rows=db.execute(stmt.order_by(ClientServicePlan.is_active.desc(),ClientServicePlan.id.desc())).all() stmt.join(Client, Client.id == ClientServicePlan.client_id)
return templates.TemplateResponse("modules/services/templates/services/subscriptions/list.html",_ctx(request,db,user,title="Client Service Subscriptions",rows=rows,q=q,include_inactive=include_inactive)) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServicePlan.service_catalogue_id)
finally: db.close() .where(
(Client.client_name.ilike(term))
| (Client.client_code.ilike(term))
| (ServiceCatalogue.service_name.ilike(term))
| (ServiceCatalogue.service_code.ilike(term))
)
)
rows = db.execute(
stmt.order_by(ClientServicePlan.is_active.desc(), ClientServicePlan.id.desc())
).all()
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/list.html",
_ctx(
request,
db,
user,
title="Client Service Subscriptions",
rows=rows,
q=q,
include_inactive=include_inactive,
),
)
finally:
db.close()
@router.get("/{plan_id}/edit")
def subscription_master_edit_page(
request: Request,
plan_id: int,
saved: int = 0,
updated_engagements: int = 0,
updated_tasks: 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)
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
)
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/edit.html",
_ctx(
request,
db,
user,
title="Edit Client Service Subscription",
plan=plan,
partners=partners,
managers=managers,
staff_users=staff_users,
review_partners=review_partners,
saved=saved,
updated_engagements=updated_engagements,
updated_tasks=updated_tasks,
error=error,
),
)
finally:
db.close()
@router.post("/{plan_id}/edit")
def subscription_master_edit_submit(
request: Request,
plan_id: int,
default_partner_user_id: str = 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(""),
status: str = Form("active"),
auto_generate_periods: str | None = Form(None),
remarks: str = Form(""),
propagation_mode: str = Form("future_only"),
reassignment_effective_from: str = Form(""),
reassignment_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)
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
)
try:
new_partner_id = _allowed_id(
_optional_int(default_partner_user_id), {row.id for row in partners}, "partner"
)
requested_performing_id = _allowed_id(
_optional_int(default_performing_partner_user_id),
{row.id for row in partners},
"performing_partner",
)
new_performing_id = requested_performing_id or new_partner_id
new_manager_id = _allowed_id(
_optional_int(default_manager_user_id), {row.id for row in managers}, "manager"
)
new_staff_id = _allowed_id(
_optional_int(default_staff_user_id), {row.id for row in staff_users}, "staff"
)
new_review_partner_id = _allowed_id(
_optional_int(default_review_partner_user_id),
{row.id for row in review_partners},
"review_partner",
)
plan_effective_from = parse_date(effective_from)
plan_effective_to = parse_date(effective_to)
change_effective_from = parse_date(reassignment_effective_from)
except (TypeError, ValueError) as exc:
code = str(exc) if str(exc) else "validation"
db.rollback()
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error={code}", status_code=303
)
allowed_statuses = {item[0] for item in SUBSCRIPTION_STATUSES}
normalized_status = (status or "active").strip().lower()
if normalized_status not in allowed_statuses:
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error=status", status_code=303
)
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error=dates", status_code=303
)
if propagation_mode not in {"future_only", "open_engagements"}:
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?error=propagation", status_code=303
)
old_staff_id = plan.default_staff_user_id
plan.default_partner_user_id = new_partner_id
plan.default_performing_partner_user_id = new_performing_id
plan.default_manager_user_id = new_manager_id
plan.default_staff_user_id = new_staff_id
plan.default_review_partner_user_id = new_review_partner_id
plan.effective_from = plan_effective_from
plan.effective_to = plan_effective_to
plan.auto_generate_periods = auto_generate_periods is not None
plan.status = normalized_status
plan.is_active = normalized_status not in {"inactive", "cancelled"}
plan.remarks = remarks.strip() or None
plan.updated_by_user_id = user.id
plan.updated_at_utc = datetime.now(timezone.utc)
updated_engagement_count = 0
updated_task_count = 0
if propagation_mode == "open_engagements":
engagements = db.execute(
select(ClientServiceSubscription).where(
ClientServiceSubscription.service_plan_id == plan.id,
ClientServiceSubscription.tenant_id == tenant_id,
)
).scalars().all()
affected_ids: list[int] = []
for row in engagements:
if not _engagement_is_open(row):
continue
if not _engagement_is_effective(row, change_effective_from):
continue
row.assigned_partner_user_id = new_partner_id
row.performing_partner_user_id = new_performing_id
row.assigned_manager_user_id = new_manager_id
row.assigned_staff_user_id = new_staff_id
row.review_partner_user_id = new_review_partner_id
row.updated_by_user_id = user.id
row.updated_at_utc = datetime.now(timezone.utc)
if reassignment_reason.strip():
note = (
f"Subscription team updated on {datetime.now(timezone.utc).date().isoformat()} "
f"by user {user.id}: {reassignment_reason.strip()}"
)
row.remarks = f"{row.remarks.strip()}\n{note}" if row.remarks else note
affected_ids.append(row.id)
updated_engagement_count += 1
if affected_ids and old_staff_id != new_staff_id:
task_stmt = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.subscription_id.in_(affected_ids),
ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.is_locked.is_(False),
ClientServiceTaskInstance.status.notin_(("completed", "cancelled")),
)
if old_staff_id is None:
task_stmt = task_stmt.where(
ClientServiceTaskInstance.assigned_to_user_id.is_(None),
func.lower(func.coalesce(ClientServiceTaskInstance.default_role_name, "")) == "staff",
)
else:
task_stmt = task_stmt.where(
ClientServiceTaskInstance.assigned_to_user_id == old_staff_id
)
tasks = db.execute(task_stmt).scalars().all()
for task in tasks:
task.assigned_to_user_id = new_staff_id
task.updated_by_user_id = user.id
task.updated_at_utc = datetime.now(timezone.utc)
updated_task_count += 1
db.commit()
return RedirectResponse(
f"/services/subscriptions/{plan.id}/edit?saved=1"
f"&updated_engagements={updated_engagement_count}"
f"&updated_tasks={updated_task_count}",
status_code=303,
)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{plan_id}") @router.get("/{plan_id}")
def subscription_master_detail(request: Request, plan_id: int): def subscription_master_detail(request: Request, plan_id: int):
db = CommonSessionLocal() db = CommonSessionLocal()
try: try:
user = get_current_user(request, db=db) user = get_current_user(request, db=db)
if not user: return RedirectResponse("/login",303) if not user:
try: require_permission(db,user,"clients.view") return RedirectResponse("/login", 303)
try:
require_permission(db, user, "clients.view")
except Exception: except Exception:
from app.core.http_responses import ui_access_denied return _denied()
return ui_access_denied()
tenant_id = _tenant_id(request, user) tenant_id = _tenant_id(request, user)
plan=db.execute(select(ClientServicePlan).options(selectinload(ClientServicePlan.client),selectinload(ClientServicePlan.catalogue),selectinload(ClientServicePlan.default_partner),selectinload(ClientServicePlan.default_performing_partner),selectinload(ClientServicePlan.default_manager),selectinload(ClientServicePlan.default_staff),selectinload(ClientServicePlan.default_review_partner)).where(ClientServicePlan.id==plan_id,ClientServicePlan.tenant_id==tenant_id)).scalar_one_or_none() branch_id = _branch_id(request, user)
if not plan: return RedirectResponse("/services/subscriptions",303) plan = _load_plan(db, plan_id=plan_id, tenant_id=tenant_id, branch_id=branch_id)
engagements=db.execute(select(ClientServiceSubscription).where(ClientServiceSubscription.service_plan_id==plan.id).order_by(ClientServiceSubscription.financial_year.desc(),ClientServiceSubscription.period_label.asc(),ClientServiceSubscription.id.desc())).scalars().all() if not plan:
return templates.TemplateResponse("modules/services/templates/services/subscriptions/detail.html",_ctx(request,db,user,title="Client Service Subscription",plan=plan,engagements=engagements)) return RedirectResponse("/services/subscriptions", 303)
finally: db.close() engagements = db.execute(
select(ClientServiceSubscription)
.where(ClientServiceSubscription.service_plan_id == plan.id)
.order_by(
ClientServiceSubscription.financial_year.desc(),
ClientServiceSubscription.period_label.asc(),
ClientServiceSubscription.id.desc(),
)
).scalars().all()
permissions = set(get_user_permissions(db, user.id))
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/detail.html",
_ctx(
request,
db,
user,
title="Client Service Subscription",
plan=plan,
engagements=engagements,
can_edit_subscription="clients.edit" in permissions,
),
)
finally:
db.close()
@@ -20,6 +20,12 @@
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"> class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Back Back
</a> </a>
{% if can_edit_subscription %}
<a href="/services/subscriptions/{{ plan.id }}/edit"
class="rounded-xl border border-brand-300 px-4 py-2 text-sm font-medium text-brand-700 hover:bg-brand-50">
Edit Subscription
</a>
{% 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">
Create Engagement Create Engagement
@@ -0,0 +1,138 @@
{% 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-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Edit Client Service Subscription</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 hover:bg-slate-50">
Back
</a>
</div>
{% if saved %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
Subscription defaults saved. {{ updated_engagements }} open engagement(s) and {{ updated_tasks }} unfinished Staff task(s) were updated.
</div>
{% endif %}
{% if error %}
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
The subscription could not be saved. Please verify the selected team, dates, status and propagation option.
</div>
{% endif %}
<form method="post" action="/services/subscriptions/{{ plan.id }}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Default Assignment Team</h3>
<p class="mt-1 text-sm text-slate-500">These defaults will be used for newly generated engagements.</p>
<div class="mt-5 grid gap-5 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Engagement Partner</label>
<select name="default_partner_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">No default Partner</option>
{% for row in partners %}<option value="{{ row.id }}" {% if plan.default_partner_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Performing Partner</label>
<select name="default_performing_partner_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">Same as Engagement Partner</option>
{% for row in partners %}<option value="{{ row.id }}" {% if plan.default_performing_partner_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Manager</label>
<select name="default_manager_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">No default Manager</option>
{% for row in managers %}<option value="{{ row.id }}" {% if plan.default_manager_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Staff</label>
<select name="default_staff_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">No default Staff</option>
{% for row in staff_users %}<option value="{{ row.id }}" {% if plan.default_staff_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Review Partner</label>
<select name="default_review_partner_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">Not required / no default</option>
{% for row in review_partners %}<option value="{{ row.id }}" {% if plan.default_review_partner_user_id == row.id %}selected{% endif %}>{{ row.full_name or row.email }}</option>{% endfor %}
</select>
</div>
</div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Subscription Settings</h3>
<div class="mt-5 grid gap-5 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Effective From</label>
<input type="date" name="effective_from" value="{{ plan.effective_from or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Effective To</label>
<input type="date" name="effective_to" value="{{ plan.effective_to or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for value, label in subscription_statuses %}<option value="{{ value }}" {% if plan.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="auto_generate_periods" value="1" {% if plan.auto_generate_periods %}checked{% endif %} class="rounded border-slate-300">
Automatically generate recurring periods when the scheduler is enabled
</label>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ plan.remarks or '' }}</textarea>
</div>
</div>
</section>
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-5">
<h3 class="text-base font-semibold text-slate-900">Apply Team Change</h3>
<p class="mt-1 text-sm text-slate-600">Completed and locked engagements, completed tasks, evidence and review history are never changed.</p>
<div class="mt-4 space-y-3">
<label class="flex items-start gap-3 rounded-xl border border-amber-200 bg-white p-4">
<input type="radio" name="propagation_mode" value="future_only" checked class="mt-1">
<span><strong class="block text-sm text-slate-900">Future engagements only</strong><span class="text-sm text-slate-500">Save new defaults without changing any existing engagement.</span></span>
</label>
<label class="flex items-start gap-3 rounded-xl border border-amber-200 bg-white p-4">
<input type="radio" name="propagation_mode" value="open_engagements" class="mt-1">
<span><strong class="block text-sm text-slate-900">Future engagements and currently open engagements</strong><span class="text-sm text-slate-500">Update unlocked, non-completed engagement assignments and unfinished Staff tasks.</span></span>
</label>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Open engagements effective from</label>
<input type="date" name="reassignment_effective_from" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm">
<p class="mt-1 text-xs text-slate-500">Leave blank to update all open engagements under this subscription.</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Reason for reassignment</label>
<input type="text" name="reassignment_reason" maxlength="500" placeholder="Optional operational reason" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm">
</div>
</div>
</div>
</section>
<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 font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
<button type="submit" class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Subscription</button>
</div>
</form>
</div>
{% endblock %}