Add controlled client subscription team reassignment
This commit is contained in:
@@ -1,22 +1,41 @@
|
||||
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 sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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.templating import templates
|
||||
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.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"])
|
||||
|
||||
|
||||
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):
|
||||
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(value)
|
||||
|
||||
|
||||
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.update(extra); return data
|
||||
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("")
|
||||
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user: return RedirectResponse("/login",303)
|
||||
try: require_permission(db,user,"clients.view")
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
from app.core.http_responses import ui_access_denied
|
||||
return ui_access_denied()
|
||||
tenant_id=_tenant_id(request,user); branch_id=_branch_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())
|
||||
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))
|
||||
return _denied()
|
||||
|
||||
tenant_id = _tenant_id(request, user)
|
||||
branch_id = _branch_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()
|
||||
)
|
||||
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():
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.services.models import ServiceCatalogue
|
||||
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)))
|
||||
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()
|
||||
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))
|
||||
)
|
||||
)
|
||||
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}")
|
||||
def subscription_master_detail(request: Request, plan_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user: return RedirectResponse("/login",303)
|
||||
try: require_permission(db,user,"clients.view")
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
from app.core.http_responses import ui_access_denied
|
||||
return ui_access_denied()
|
||||
return _denied()
|
||||
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()
|
||||
if not plan: return RedirectResponse("/services/subscriptions",303)
|
||||
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()
|
||||
return templates.TemplateResponse("modules/services/templates/services/subscriptions/detail.html",_ctx(request,db,user,title="Client Service Subscription",plan=plan,engagements=engagements))
|
||||
finally: db.close()
|
||||
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)
|
||||
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">
|
||||
Back
|
||||
</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 }}"
|
||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
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 %}
|
||||
Reference in New Issue
Block a user