diff --git a/alembic/versions/20260805_client_service_plan_master.py b/alembic/versions/20260805_client_service_plan_master.py new file mode 100644 index 0000000..68fc46c --- /dev/null +++ b/alembic/versions/20260805_client_service_plan_master.py @@ -0,0 +1,36 @@ +"""add persistent client service subscription master + +Revision ID: 20260805_client_service_plan_master +Revises: 20260805_task_instance_period_label +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision="20260805_client_service_plan_master" +down_revision="20260805_task_instance_period_label" +branch_labels=None +depends_on=None + +def upgrade(): + bind=op.get_bind(); insp=inspect(bind); tables=set(insp.get_table_names()) + if "client_service_plans" not in tables: + op.create_table("client_service_plans", + sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("branch_id",sa.Integer(),sa.ForeignKey("branches.id",ondelete="SET NULL")),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("service_catalogue_id",sa.Integer(),sa.ForeignKey("service_catalogues.id",ondelete="CASCADE"),nullable=False),sa.Column("firm_service_selection_id",sa.Integer(),sa.ForeignKey("firm_service_selections.id",ondelete="SET NULL")),sa.Column("default_partner_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_performing_partner_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_manager_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_staff_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_review_partner_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("recurrence_type",sa.String(30),nullable=False,server_default="one_time"),sa.Column("effective_from",sa.Date()),sa.Column("effective_to",sa.Date()),sa.Column("auto_generate_periods",sa.Boolean(),nullable=False,server_default=sa.text("false")),sa.Column("status",sa.String(30),nullable=False,server_default="active"),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.text("true")),sa.Column("remarks",sa.Text()),sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("updated_by_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.UniqueConstraint("tenant_id","client_id","service_catalogue_id",name="uq_csp_tenant_client_service")) + for col in ("tenant_id","branch_id","client_id","service_catalogue_id","default_partner_user_id","default_performing_partner_user_id","default_manager_user_id","default_staff_user_id","default_review_partner_user_id","recurrence_type","status","is_active"): + op.create_index(f"ix_csp_{col}","client_service_plans",[col]) + cols={c["name"] for c in inspect(bind).get_columns("client_service_subscriptions")} + if "service_plan_id" not in cols: + op.add_column("client_service_subscriptions",sa.Column("service_plan_id",sa.Integer(),nullable=True)) + op.create_foreign_key("fk_css_service_plan","client_service_subscriptions","client_service_plans",["service_plan_id"],["id"],ondelete="SET NULL") + op.create_index("ix_css_service_plan_id","client_service_subscriptions",["service_plan_id"]) + op.execute(sa.text("""INSERT INTO client_service_plans (tenant_id,branch_id,client_id,service_catalogue_id,firm_service_selection_id,default_partner_user_id,default_performing_partner_user_id,default_manager_user_id,default_staff_user_id,default_review_partner_user_id,recurrence_type,status,is_active,remarks,created_by_user_id,updated_by_user_id) SELECT DISTINCT ON (s.tenant_id,s.client_id,s.service_catalogue_id) s.tenant_id,s.branch_id,s.client_id,s.service_catalogue_id,s.firm_service_selection_id,s.assigned_partner_user_id,s.performing_partner_user_id,s.assigned_manager_user_id,s.assigned_staff_user_id,s.review_partner_user_id,COALESCE(c.recurrence_type,'one_time'),'active',true,s.remarks,s.created_by_user_id,s.updated_by_user_id FROM client_service_subscriptions s JOIN service_catalogues c ON c.id=s.service_catalogue_id LEFT JOIN client_service_plans p ON p.tenant_id=s.tenant_id AND p.client_id=s.client_id AND p.service_catalogue_id=s.service_catalogue_id WHERE p.id IS NULL ORDER BY s.tenant_id,s.client_id,s.service_catalogue_id,s.id DESC""")) + op.execute(sa.text("""UPDATE client_service_subscriptions s SET service_plan_id=p.id FROM client_service_plans p WHERE s.service_plan_id IS NULL AND p.tenant_id=s.tenant_id AND p.client_id=s.client_id AND p.service_catalogue_id=s.service_catalogue_id""")) + +def downgrade(): + bind=op.get_bind(); cols={c["name"] for c in inspect(bind).get_columns("client_service_subscriptions")} + if "service_plan_id" in cols: + op.drop_index("ix_css_service_plan_id",table_name="client_service_subscriptions") + op.drop_constraint("fk_css_service_plan","client_service_subscriptions",type_="foreignkey") + op.drop_column("client_service_subscriptions","service_plan_id") + if "client_service_plans" in inspect(bind).get_table_names(): op.drop_table("client_service_plans") diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py index 34b7730..e35962d 100644 --- a/app/modules/services/bulk_imports.py +++ b/app/modules/services/bulk_imports.py @@ -25,6 +25,7 @@ from app.modules.services.models import ( from app.modules.services.services import normalize_code, normalize_engagement_type from app.modules.services.client_services import ( assessment_year_from_financial_year, + attach_engagement_to_plan, normalize_financial_year, normalize_period_label, review_partner_required_for_engagement, @@ -842,6 +843,10 @@ def import_client_service_assignments( sub.is_active = is_active sub.remarks = _clean(_cell(row, headers, "remarks")) or None sub.updated_by_user_id = current_user.id + attach_engagement_to_plan( + db, engagement=sub, client=client, catalogue=catalogue, + firm_selection=firm_selection, actor_user_id=current_user.id, + ) apply_due_date_rule_to_subscription(db, sub, force=True) ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False) enforce_quality_gate_on_subscription(sub) diff --git a/app/modules/services/client_services.py b/app/modules/services/client_services.py index 1d1106e..5a4b4c8 100644 --- a/app/modules/services/client_services.py +++ b/app/modules/services/client_services.py @@ -9,7 +9,7 @@ from app.modules.clients.models import Client from app.modules.core.iam.models import User from app.modules.core.rbac.models import Role, UserRole from app.modules.core.tenancy.models import Tenant -from app.modules.services.models import ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue +from app.modules.services.models import ClientServicePlan, ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue SUBSCRIPTION_STATUSES = [ ("draft", "Draft"), @@ -640,3 +640,80 @@ def enforce_quality_gate_on_subscription(subscription: ClientServiceSubscription subscription.is_active = False if not subscription.quality_block_reason: subscription.quality_block_reason = "AQMM acceptance workflow pending for assurance engagement." + + +def get_or_create_client_service_plan( + db: Session, + *, + tenant_id: int, + client: Client, + catalogue: ServiceCatalogue, + firm_selection: FirmServiceSelection | None, + branch_id: int | None, + partner_user_id: int | None, + performing_partner_user_id: int | None, + manager_user_id: int | None, + staff_user_id: int | None, + review_partner_user_id: int | None, + actor_user_id: int | None, + remarks: str | None = None, +) -> ClientServicePlan: + plan = db.execute( + select(ClientServicePlan).where( + ClientServicePlan.tenant_id == tenant_id, + ClientServicePlan.client_id == client.id, + ClientServicePlan.service_catalogue_id == catalogue.id, + ) + ).scalar_one_or_none() + if plan is None: + plan = ClientServicePlan( + tenant_id=tenant_id, + client_id=client.id, + service_catalogue_id=catalogue.id, + created_by_user_id=actor_user_id, + ) + db.add(plan) + plan.branch_id = branch_id or getattr(client, "branch_id", None) + plan.firm_service_selection_id = getattr(firm_selection, "id", None) + plan.default_partner_user_id = partner_user_id or getattr(client, "partner_id", None) + plan.default_performing_partner_user_id = performing_partner_user_id or getattr(client, "default_performing_partner_user_id", None) or plan.default_partner_user_id + plan.default_manager_user_id = manager_user_id + plan.default_staff_user_id = staff_user_id + plan.default_review_partner_user_id = review_partner_user_id or getattr(client, "default_review_partner_user_id", None) + plan.recurrence_type = normalized_recurrence_type(getattr(catalogue, "recurrence_type", None)) or "one_time" + plan.auto_generate_periods = recurrence_requires_period(plan.recurrence_type) + plan.status = "active" + plan.is_active = True + if remarks and not plan.remarks: + plan.remarks = remarks + plan.updated_by_user_id = actor_user_id + db.flush() + return plan + + +def attach_engagement_to_plan( + db: Session, + *, + engagement: ClientServiceSubscription, + client: Client, + catalogue: ServiceCatalogue, + firm_selection: FirmServiceSelection | None, + actor_user_id: int | None, +) -> ClientServicePlan: + plan = get_or_create_client_service_plan( + db, + tenant_id=engagement.tenant_id, + client=client, + catalogue=catalogue, + firm_selection=firm_selection, + branch_id=engagement.branch_id, + partner_user_id=engagement.assigned_partner_user_id, + performing_partner_user_id=getattr(engagement, "performing_partner_user_id", None), + manager_user_id=engagement.assigned_manager_user_id, + staff_user_id=engagement.assigned_staff_user_id, + review_partner_user_id=engagement.review_partner_user_id, + actor_user_id=actor_user_id, + remarks=engagement.remarks, + ) + engagement.service_plan_id = plan.id + return plan diff --git a/app/modules/services/client_services_ui_old.py b/app/modules/services/client_services_ui_old.py index e9d2cb7..42077e0 100644 --- a/app/modules/services/client_services_ui_old.py +++ b/app/modules/services/client_services_ui_old.py @@ -10,6 +10,7 @@ from app.core.templating import templates from app.modules.services.models import ClientServiceSubscription from app.modules.services.client_services import ( SUBSCRIPTION_STATUSES, + attach_engagement_to_plan, get_enabled_firm_service, get_existing_subscription, get_subscription, @@ -216,6 +217,12 @@ def subscription_create_submit( row.remarks = remarks.strip() or None row.is_active = is_active is not None row.updated_by_user_id = user.id + client = db.get(__import__("app.modules.clients.models", fromlist=["Client"]).Client, client_id) + if client is not None: + attach_engagement_to_plan( + db, engagement=row, client=client, catalogue=firm_selection.catalogue, + firm_selection=firm_selection, actor_user_id=user.id, + ) db.commit() db.refresh(row) diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py index 5f31c96..0fe4e79 100644 --- a/app/modules/services/engagements_ui.py +++ b/app/modules/services/engagements_ui.py @@ -22,6 +22,7 @@ from app.modules.core.iam.models import User from app.modules.services.client_services import ( SUBSCRIPTION_STATUSES, assessment_year_from_financial_year, + attach_engagement_to_plan, current_financial_year, get_enabled_firm_service, get_existing_subscription, @@ -366,6 +367,10 @@ def subscription_create_submit( row.remarks = remarks.strip() or None row.is_active = is_active is not None row.updated_by_user_id = user.id + attach_engagement_to_plan( + db, engagement=row, client=client, catalogue=firm_selection.catalogue, + firm_selection=firm_selection, actor_user_id=user.id, + ) apply_due_date_rule_to_subscription(db, row, force=True) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) enforce_quality_gate_on_subscription(row) @@ -619,6 +624,10 @@ def subscription_bulk_create_submit( ) db.add(row) db.flush() + attach_engagement_to_plan( + db, engagement=row, client=client, catalogue=firm_selection.catalogue, + firm_selection=firm_selection, actor_user_id=user.id, + ) apply_due_date_rule_to_subscription(db, row, force=True) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) enforce_quality_gate_on_subscription(row) diff --git a/app/modules/services/models.py b/app/modules/services/models.py index 8bf0f81..eef4f46 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -371,6 +371,54 @@ class ServiceDueDateExtension(CommonBase): due_rule = relationship("ServiceDueDateRule") +class ClientServicePlan(CommonBase): + """Persistent client-level service subscription from which period engagements are created.""" + + __tablename__ = "client_service_plans" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "client_id", "service_catalogue_id", + name="uq_csp_tenant_client_service", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) + firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True) + + default_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + default_performing_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + default_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + default_staff_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + + recurrence_type: Mapped[str] = mapped_column(String(30), nullable=False, default="one_time", server_default="one_time", index=True) + effective_from: Mapped[date | None] = mapped_column(Date, nullable=True) + effective_to: Mapped[date | None] = mapped_column(Date, nullable=True) + auto_generate_periods: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", server_default="active", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true", index=True) + remarks: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + client = relationship("Client") + catalogue = relationship("ServiceCatalogue") + firm_selection = relationship("FirmServiceSelection") + default_partner = relationship("User", foreign_keys=[default_partner_user_id]) + default_performing_partner = relationship("User", foreign_keys=[default_performing_partner_user_id]) + default_manager = relationship("User", foreign_keys=[default_manager_user_id]) + default_staff = relationship("User", foreign_keys=[default_staff_user_id]) + default_review_partner = relationship("User", foreign_keys=[default_review_partner_user_id]) + engagements = relationship("ClientServiceSubscription", back_populates="service_plan") + + class ClientServiceSubscription(CommonBase): """Firm-level subscription of an enabled service to a specific client.""" @@ -390,6 +438,7 @@ class ClientServiceSubscription(CommonBase): tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) + service_plan_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_plans.id", ondelete="SET NULL"), nullable=True, index=True) client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True) @@ -447,6 +496,7 @@ class ClientServiceSubscription(CommonBase): created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + service_plan = relationship("ClientServicePlan", back_populates="engagements") client = relationship("Client") catalogue = relationship("ServiceCatalogue") due_date_rule = relationship("ServiceDueDateRule", foreign_keys=[due_date_rule_id]) diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py new file mode 100644 index 0000000..16ce501 --- /dev/null +++ b/app/modules/services/subscriptions_ui.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import func, 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.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 + +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) + +def _branch_id(request, user): + value = request.session.get("active_branch_id") + if value in (None, "", 0, "0"): + 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 + +@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") + 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)) + 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() + +@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") + except Exception: + from app.core.http_responses import ui_access_denied + return ui_access_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() diff --git a/app/modules/services/templates/services/subscriptions/detail.html b/app/modules/services/templates/services/subscriptions/detail.html new file mode 100644 index 0000000..c4580ce --- /dev/null +++ b/app/modules/services/templates/services/subscriptions/detail.html @@ -0,0 +1,4 @@ +{% extends "base/layout.html" %} +{% block content %}
+

Subscription defaults

Recurrence
{{ plan.recurrence_type|replace('_',' ')|title }}
Status
{{ plan.status|title }}
Engagement Partner
{{ plan.default_partner.full_name if plan.default_partner else '-' }}
Performing Partner
{{ plan.default_performing_partner.full_name if plan.default_performing_partner else '-' }}
Manager
{{ plan.default_manager.full_name if plan.default_manager else '-' }}
Staff
{{ plan.default_staff.full_name if plan.default_staff else '-' }}
Review Partner
{{ plan.default_review_partner.full_name if plan.default_review_partner else '-' }}
Auto-generate periods
{{ 'Yes' if plan.auto_generate_periods else 'No' }}
+

Engagement instances

{% for row in engagements %}{% else %}{% endfor %}
FYPeriodDue dateStatus
{{ row.financial_year }}{{ row.period_label or '-' }}{{ row.current_due_date or '-' }}{{ row.status|title }}View
No engagement instances generated.
{% endblock %} diff --git a/app/modules/services/templates/services/subscriptions/list.html b/app/modules/services/templates/services/subscriptions/list.html new file mode 100644 index 0000000..000419c --- /dev/null +++ b/app/modules/services/templates/services/subscriptions/list.html @@ -0,0 +1,6 @@ +{% extends "base/layout.html" %} +{% block content %} +
+
+
{% for plan,count,latest_due in rows %}{% else %}{% endfor %}
ClientServiceRecurrenceDefault TeamEngagementsStatus
{{ plan.client.client_name }}
{{ plan.client.client_code or '' }}
{{ plan.catalogue.service_name }}
{{ plan.catalogue.service_code }}
{{ plan.recurrence_type|replace('_',' ')|title }}Partner: {{ plan.default_partner.full_name if plan.default_partner else '-' }}
Performing: {{ plan.default_performing_partner.full_name if plan.default_performing_partner else '-' }}
Manager: {{ plan.default_manager.full_name if plan.default_manager else '-' }}
Staff: {{ plan.default_staff.full_name if plan.default_staff else '-' }}
{{ count or 0 }}{% if latest_due %}
Latest due: {{ latest_due }}
{% endif %}
{{ plan.status|title }}View
No client service subscriptions found.
+{% endblock %} diff --git a/app/ui/app.py b/app/ui/app.py index 4ce9e55..a7c36ba 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -14,6 +14,7 @@ from app.modules.core.iam.ui import router as iam_ui_router from app.modules.core.rbac.ui import router as rbac_ui_router from app.modules.services.ui import router as services_ui_router from app.modules.services.engagements_ui import router as engagements_ui_router +from app.modules.services.subscriptions_ui import router as service_subscriptions_ui_router from app.modules.services.work_tracker_ui import router as work_tracker_ui_router from app.modules.billing.ui import router as billing_ui_router from app.modules.platform_billing.ui import router as platform_billing_ui_router @@ -80,6 +81,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(employee_portal_router) app.include_router(consultants_ui_router) app.include_router(engagements_ui_router) + app.include_router(service_subscriptions_ui_router) app.include_router(client_portal_router) app.include_router(consultant_portal_router) diff --git a/app/ui/templates/components/partner_navigation_v2.html b/app/ui/templates/components/partner_navigation_v2.html index 415a2fb..e5e21fa 100644 --- a/app/ui/templates/components/partner_navigation_v2.html +++ b/app/ui/templates/components/partner_navigation_v2.html @@ -77,8 +77,13 @@ { 'label': 'Engagements', 'visible': true, - 'active': _partner_path.startswith('/services/engagements') or _partner_path.startswith('/services/bulk-imports'), + 'active': _partner_path.startswith('/services/subscriptions') or _partner_path.startswith('/services/engagements') or _partner_path.startswith('/services/bulk-imports'), 'children': [ + { + 'label': 'Client Subscriptions', + 'url': '/services/subscriptions', + 'active': _partner_path.startswith('/services/subscriptions') + }, { 'label': 'All Engagements', 'url': '/services/engagements',